diff --git a/en/00_Getting_Started/00_Server_Requirements.md b/en/00_Getting_Started/00_Server_Requirements.md index 6def257a2..6ead9ab61 100644 --- a/en/00_Getting_Started/00_Server_Requirements.md +++ b/en/00_Getting_Started/00_Server_Requirements.md @@ -80,13 +80,6 @@ also needs write access for the webserver user to the following locations: [Manifests](/developer_guides/execution_pipeline/manifests), [Object Caching](/developer_guides/performance/caching) and [Partial Template Caching](/developer_guides/templates/partial_template_caching). See [Environment Management](/getting_started/environment_management). -- `.graphql-generated`: silverstripe/graphql uses this directory. This is where your schema is - stored once it [has been built](/developer_guides/graphql/getting_started/building_the_schema). Best practice - is to create it ahead of time, but if the directory doesn't exist and your project root is writable, the GraphQL - module will create it for you. -- `public/_graphql`: silverstripe/graphql uses this directory. It's used for - [schema introspection](/developer_guides/graphql/tips_and_tricks#schema-introspection). You should treat this folder - the same way you treat the `.graphql-generated` folder. If you aren't explicitly [packaging](#building-packaging-deployment) your Silverstripe CMS project during your deployment process, additional write access may be required to generate supporting @@ -159,11 +152,6 @@ noisy, here's some pointers for auto-generated files to trigger and include in a - `public/_resources/`: Frontend resources copied from the (inaccessible) `vendor/` folder via [silverstripe/vendor-plugin](https://github.com/silverstripe/vendor-plugin). See [Templates: Requirements](/developer_guides/templates/requirements#exposing-resources-webroot). -- `.graphql-generated/` and `public/_graphql/`: Schema and type definitions required by CMS and any GraphQL API endpoint. - Generated by - [silverstripe/graphql](https://github.com/silverstripe/silverstripe-graphql). See - [building the schema](/developer_guides/graphql/getting_started/building_the_schema) and - [deploying the schema](/developer_guides/graphql/getting_started/deploying_the_schema). - Various recipes create default files in `app/` and `public/` on `composer install` and `composer update` via [silverstripe/recipe-plugin](https://github.com/silverstripe/recipe-plugin). diff --git a/en/02_Developer_Guides/00_Model/10_Versioning.md b/en/02_Developer_Guides/00_Model/10_Versioning.md index eda3509d2..f320f42f2 100644 --- a/en/02_Developer_Guides/00_Model/10_Versioning.md +++ b/en/02_Developer_Guides/00_Model/10_Versioning.md @@ -1020,410 +1020,36 @@ See [Reading versions by stage](#reading-versions-by-stage) for more about using ## Using the history viewer -You can use the React and GraphQL driven history viewer UI to display historic changes and -comparisons for a versioned DataObject. This is automatically enabled for SiteTree objects and content blocks in -[dnadesign/silverstripe-elemental](https://github.com/dnadesign/silverstripe-elemental). - -> [!WARNING] -> Because of the lack of specificity in the `HistoryViewer.Form_ItemEditForm` scope used when injecting the history viewer to the DOM, only one model can have a working history panel at a time, with exception to `SiteTree` which has its own history viewer scope. For example, if you already have `dnadesign/silverstripe-elemental` installed, the custom history viewer instance injected as a part of this documentation will *break* the one provided by the elemental module. -> -> There are ways you can get around this limitation. You may wish to put some conditional logic in `app/client/src/boot/index.js` below to only perform the transformations if the current location is within a specific model admin, for example. - -If you want to enable the history viewer for a custom versioned DataObject, you will need to: - -- Expose GraphQL scaffolding -- Add the necessary GraphQL queries and mutations to your module -- Register your GraphQL queries and mutations with Injector -- Add a HistoryViewerField to the DataObject's `getCMSFields` - -> [!WARNING] -> **Please note:** these examples are given in the context of project-level customisation. You may need to adjust -> the webpack configuration slightly for use in a module. - -### Setup {#history-viewer-setup} - -This example assumes you have some `DataObject` model and somewhere to view that model (e.g. in a `ModelAdmin`). We'll walk you through the steps required to add some JavaScript to tell the history viewer how to handle requests for your model. - -For this example we'll start with this simple `DataObject`: +You can add the [`HistoryViewerField`](api:SilverStripe\VersionedAdmin\Forms\HistoryViewerField) to the edit form of any [`DataObject`](api:SilverStripe\ORM\DataObject) with the [`Versioned`](api:SilverStripe\Versioned\Versioned) extension. This will allow CMS users revert to a previous version of the record. ```php -namespace App\Model; +// app/src/Models/MyDataObject.php +namespace App\Models; use SilverStripe\ORM\DataObject; use SilverStripe\Versioned\Versioned; +use SilverStripe\VersionedAdmin\Forms\HistoryViewerField; -class MyVersionedObject extends DataObject +class MyDataObject extends DataObject { - private static $table_name = 'App_MyVersionedObject'; - - private static $db = [ - 'Title' => 'Varchar', - ]; + // ... private static $extensions = [ Versioned::class, ]; - // ... -} -``` - -#### Configure frontend asset building {#history-viewer-js} - -If you haven't already configured frontend asset (JavaScript/CSS) building for your project, you will need to configure some basic -packages to be built in order to enable history viewer functionality. This section includes a very basic webpack configuration which uses [@silverstripe/webpack-config](https://www.npmjs.com/package/@silverstripe/webpack-config). - -> [!TIP] -> If you have this configured for your project already, ensure you have the `@apollo/client` and `graphql-tag` libraries in your `package.json` -> requirements (with the appropriate version constraints from below), and skip this section. - -You can configure your directory structure like so: - -```json -// package.json -{ - "name": "my-project", - "scripts": { - "build": "yarn && NODE_ENV=production webpack --mode production --bail --progress", - "watch": "yarn && NODE_ENV=development webpack --watch --progress" - }, - "dependencies": { - "@apollo/client": "^3.7.1", - "graphql-tag": "^2.12.6" - }, - "devDependencies": { - "@silverstripe/webpack-config": "^2.0.0", - "webpack": "^5.74.0", - "webpack-cli": "^5.0.0" - }, - "engines": { - "node": "^18.x" - } -} -``` - -> [!WARNING] -> Using `@silverstripe/webpack-config` will keep your transpiled bundle size smaller and ensure you are using the correct versions of `@apollo/client` and `graphql-tag`, as these will automatically be added as [webpack externals](https://webpack.js.org/configuration/externals/). If you are not using that npm package, it is very important you use the correct versions of those dependencies. - -```js -// webpack.config.js -const Path = require('path'); -const { JavascriptWebpackConfig } = require('@silverstripe/webpack-config'); - -const PATHS = { - ROOT: Path.resolve(), - SRC: Path.resolve('app/client/src'), - DIST: Path.resolve('app/client/dist'), -}; - -module.exports = [ - new JavascriptWebpackConfig('cms-js', PATHS) - .setEntry({ - bundle: `${PATHS.SRC}/boot/index.js`, - }) - .getConfig(), -]; -``` - -```js -// app/client/src/boot/index.js - -// We'll populate this file later - for now we just need it to be sure our build setup works. -``` - -At this stage, running `yarn build` should correctly build `app/client/dist/js/bundle.js`. - -> [!WARNING] -> Don't forget to [configure your project's "exposed" folders](/developer_guides/templates/requirements/#configuring-your-project-exposed-folders) and run `composer vendor-expose` on the command line so that the browser has access to your new dist JS file. - -### Create and use GraphQL schema {#history-viewer-gql} - -The history viewer uses GraphQL queries and mutations to function. There's instructions for setting up a basic schema below. - -#### Define GraphQL schema {#define-graphql-schema} - -Only a minimal amount of data is required to be exposed via GraphQL scaffolding, and only to the "admin" GraphQL schema. -For more information, see [Working with DataObjects - Adding DataObjects to the schema](/developer_guides/graphql/working_with_dataobjects/adding_dataobjects_to_the_schema/). - -```yml -# app/_config/graphql.yml -SilverStripe\GraphQL\Schema\Schema: - schemas: - admin: - src: - - app/_graphql -``` - -```yml -# app/_graphql/models.yml -App\Model\MyVersionedObject: - fields: '*' - operations: - readOne: true - rollback: true -``` - -Once configured, flush your cache and run `dev/graphql/build` either in your browser or via sake, and explore the new GraphQL schema to ensure it loads correctly. -You can use a GraphQL application such as GraphiQL, or [`silverstripe/graphql-devtools`](https://github.com/silverstripe/silverstripe-graphql-devtools) -to view the schema and run queries from your browser: - -```bash -composer require --dev silverstripe/graphql-devtools dev-master -``` - -#### Use the GraphQL query and mutation in JavaScript - -The history viewer interface uses two main operations: - -- Read a list of versions for a DataObject -- Revert (aka rollback) to an older version of a DataObject - -`silverstripe/versioned` provides some GraphQL plugins we're taking advantage of here. See [Working with DataObjects - Versioned content](/developer_guides/graphql/working_with_dataobjects/versioning/) for more information. - -For this we need one query and one mutation: - -```js -// app/client/src/state/readOneMyVersionedObjectQuery.js -import { graphql } from '@apollo/client/react/hoc'; -import gql from 'graphql-tag'; - -// Note that "readOneMyVersionedObject" is the query name in the schema, while -// "ReadHistoryViewerMyVersionedObject" is an arbitrary name we're using for this invocation -// of the query -const query = gql` -query ReadHistoryViewerMyVersionedObject ($id: ID!, $limit: Int!, $offset: Int!) { - readOneMyVersionedObject( - versioning: { - mode: ALL_VERSIONS - }, - filter: { - id: { eq: $id } - } - ) { - id - versions (limit: $limit, offset: $offset, sort: { - version: DESC - }) { - pageInfo { - totalCount - } - nodes { - version - author { - firstName - surname - } - publisher { - firstName - surname - } - deleted - draft - published - liveVersion - latestDraftVersion - lastEdited - } - } - } - } -`; - -const config = { - options({ recordId, limit, page }) { - return { - variables: { - limit, - offset: ((page || 1) - 1) * limit, - id: recordId, - // Never read from the cache. Saved pages should stale the query, and these queries - // happen outside the scope of apollo's cache. This view is loaded asynchronously anyway, - // so caching doesn't make any sense until we're full React/GraphQL. - fetchPolicy: 'network-only', - } - }; - }, - props({ - data: { - error, - refetch, - readOneMyVersionedObject, - loading: networkLoading, - }, - ownProps: { - actions = { - versions: {} - }, - limit, - recordId, - }, - }) { - const versions = readOneMyVersionedObject || null; - - const errors = error && error.graphQLErrors && - error.graphQLErrors.map((graphQLError) => graphQLError.message); - - return { - loading: networkLoading || !versions, - versions, - graphQLErrors: errors, - actions: { - ...actions, - versions: { - ...versions, - goToPage(page) { - refetch({ - offset: ((page || 1) - 1) * limit, - limit, - id: recordId, - }); - } - }, - }, - }; - }, -}; - -export { query, config }; - -export default graphql(query, config); -``` - -```js -// app/client/src/state/revertToMyVersionedObjectVersionMutation.js -import { graphql } from '@apollo/client/react/hoc'; -import gql from 'graphql-tag'; - -// Note that "rollbackMyVersionedObject" is the mutation name in the schema, while -// "revertToMyVersionedObject" is an arbitrary name we're using for this invocation -// of the mutation -const mutation = gql` -mutation revertToMyVersionedObject($id:ID!, $toVersion:Int!) { - rollbackMyVersionedObject( - id: $id - toVersion: $toVersion - ) { - id - } -} -`; - -const config = { - props: ({ mutate, ownProps: { actions } }) => { - const revertToVersion = (id, toVersion) => mutate({ - variables: { - id, - toVersion, - }, - }); - - return { - actions: { - ...actions, - revertToVersion, - }, - }; - }, - options: { - // Refetch versions after mutation is completed - refetchQueries: ['ReadHistoryViewerMyVersionedObject'] - } -}; - -export { mutation, config }; - -export default graphql(mutation, config); -``` - -#### Register your GraphQL query and mutation with `Injector` - -Once your GraphQL query and mutation are created you will need to tell the JavaScript Injector about them. -This does two things: - -- Allow them to be loaded by core components. -- Allow Injector to provide them in certain contexts. They should be available for `MyVersionedObject` history viewer - instances, but not for CMS pages for example. - -```js -// app/client/src/boot/index.js - -/* global window */ -import Injector from 'lib/Injector'; -import readOneMyVersionedObjectQuery from 'state/readOneMyVersionedObjectQuery'; -import revertToMyVersionedObjectVersionMutation from 'state/revertToMyVersionedObjectVersionMutation'; - -window.document.addEventListener('DOMContentLoaded', () => { - // Register GraphQL operations with Injector as transformations - Injector.transform( - 'myversionedobject-history', // this name is arbitrary - (updater) => { - // Add CMS page history GraphQL query to the HistoryViewer - updater.component( - 'HistoryViewer.Form_ItemEditForm', - readOneMyVersionedObjectQuery, - 'MyVersionedObjectHistoryViewer' // this name is arbitrary - ); - } - ); - - Injector.transform( - 'myversionedobject-history-revert', // this name is arbitrary - (updater) => { - // Add CMS page revert GraphQL mutation to the HistoryViewerToolbar - updater.component( - // NOTE: The "App_MyVersionedObject" portion here is taken from table_name of the model - 'HistoryViewerToolbar.VersionedAdmin.HistoryViewer.App_MyVersionedObject.HistoryViewerVersionDetail', - revertToMyVersionedObjectVersionMutation, - 'MyVersionedObjectRevertMutation' // this name is arbitrary - ); + public function getCMSFields() + { + $fields = parent::getCMSFields(); + $fields->addFieldToTab( + 'Root.History', + HistoryViewerField::create('HistoryViewer') + ); + return $fields; } - ); -}); -``` - -For more information, see [Using Injector to customise GraphQL queries](/developer_guides/customising_the_admin_interface/react_redux_and_graphql#using-injector-to-customise-graphql-queries) and [Transforming services using middleware](/developer_guides/customising_the_admin_interface/reactjs_redux_and_graphql/#transforming-services-using-middleware). - -### Adding the `HistoryViewerField` - -Firstly, ensure your JavaScript bundle is included throughout the CMS: - -```yml ---- -Name: CustomAdmin -After: - - 'versionedadmincmsconfig' - - 'versionededitform' - - 'cmsscripts' - - 'elemental' # Only needed if silverstripe-elemental is installed ---- -SilverStripe\Admin\LeftAndMain: - extra_requirements_javascript: - - app/client/dist/js/bundle.js -``` - -Then you can add the [HistoryViewerField](api:SilverStripe\VersionedAdmin\Forms\HistoryViewerField) to your model's CMS -fields in the same way as any other form field: - -```php -use SilverStripe\Forms\FieldList; -use SilverStripe\VersionedAdmin\Forms\HistoryViewerField; - -public function getCMSFields() -{ - $this->beforeUpdateCMSFields(function (FieldList $fields) { - $fields->addFieldToTab('Root.History', HistoryViewerField::create('MyObjectHistory')); - }); - return parent::getCMSFields(); } ``` -### Previewable `DataObject` models - -The history viewer will automatically detect and render a side-by-side preview panel for DataObjects that implement -[CMSPreviewable](api:SilverStripe\ORM\CMSPreviewable). Please note that if you are adding this functionality, you -will also need to expose the `AbsoluteLink` field in your GraphQL read scaffolding, and add it to the fields in -`readOneMyVersionedObjectQuery`. - ## API documentation - [Versioned](api:SilverStripe\Versioned\Versioned) diff --git a/en/02_Developer_Guides/09_Security/04_Sudo_Mode.md b/en/02_Developer_Guides/09_Security/04_Sudo_Mode.md index e307a9db1..4d50bd26a 100644 --- a/en/02_Developer_Guides/09_Security/04_Sudo_Mode.md +++ b/en/02_Developer_Guides/09_Security/04_Sudo_Mode.md @@ -69,7 +69,7 @@ on success. > [!WARNING] > The `WithSudoMode` HOC is exposed via [Webpack's expose-loader plugin](https://webpack.js.org/loaders/expose-loader/). You will need to add it as a [webpack external](https://webpack.js.org/configuration/externals/) to use it. The recommended way to do this is via the [@silverstripe/webpack-config npm package](https://www.npmjs.com/package/@silverstripe/webpack-config) which handles all the external configuration for you. -You can get the injector to apply the HOC to your component automatically using [injector transformations](/developer_guides/customising_the_admin_interface/reactjs_redux_and_graphql/#transforming-services-using-middleware): +You can get the injector to apply the HOC to your component automatically using [injector transformations](/developer_guides/customising_the_admin_interface/reactjs_and_redux/#transforming-services-using-middleware): ```js import WithSudoMode from 'containers/SudoMode/SudoMode'; diff --git a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/02_CMS_Architecture.md b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/02_CMS_Architecture.md index c4673387b..1f821b305 100644 --- a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/02_CMS_Architecture.md +++ b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/02_CMS_Architecture.md @@ -236,7 +236,7 @@ correctly configured form. > [!WARNING] > The following documentation regarding Entwine does not apply to React components or sections powered by React. > If you're developing new functionality in React powered sections please refer to -> [React, Redux, and GraphQL](/developer_guides/customising_the_admin_interface/reactjs_redux_and_graphql/). +> [React and Redux](/developer_guides/customising_the_admin_interface/reactjs_and_redux/). jQuery.entwine is a library which allows us to attach behaviour to DOM elements in a flexible and structured manner. diff --git a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/06_Javascript_Development.md b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/06_Javascript_Development.md index 8da3c194c..6fe2b2fa3 100644 --- a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/06_Javascript_Development.md +++ b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/06_Javascript_Development.md @@ -24,7 +24,7 @@ There are many ways to solve the problem of transpiling. The toolchain we use in > [!WARNING] > The following documentation regarding jQuery, jQueryUI and Entwine does not apply to React components or sections powered by React. > If you're developing new functionality in React powered sections please refer to -> [ReactJS, Redux, and GraphQL](./reactjs_redux_and_graphql). +> [ReactJS and Redux](./reactjs_and_redux). We predominantly use [jQuery](https://jquery.com) as our abstraction library for DOM related programming, within the Silverstripe CMS and certain framework aspects. diff --git a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/07_jQuery_Entwine.md b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/07_jQuery_Entwine.md index 19df1c8bb..16c4909a8 100644 --- a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/07_jQuery_Entwine.md +++ b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/07_jQuery_Entwine.md @@ -8,7 +8,7 @@ iconBrand: js > [!WARNING] > The following documentation regarding jQuery and Entwine does not apply to React components or sections powered by React. > If you're developing new functionality in React powered sections please refer to -> [React, Redux, and GraphQL](/developer_guides/customising_the_admin_interface/reactjs_redux_and_graphql/). +> [React and Redux](/developer_guides/customising_the_admin_interface/reactjs_and_redux/). jQuery Entwine was originally written by [Hamish Friedlander](https://github.com/hafriedlander/jquery.entwine). diff --git a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/08_ReactJS_Redux_and_GraphQL.md b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/08_ReactJS_and_Redux.md similarity index 53% rename from en/02_Developer_Guides/15_Customising_the_Admin_Interface/08_ReactJS_Redux_and_GraphQL.md rename to en/02_Developer_Guides/15_Customising_the_Admin_Interface/08_ReactJS_and_Redux.md index 2c32996f5..499bf1827 100644 --- a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/08_ReactJS_Redux_and_GraphQL.md +++ b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/08_ReactJS_and_Redux.md @@ -1,5 +1,5 @@ --- -title: React, Redux, and GraphQL +title: React and Redux summary: Learn how to extend and customise the technologies we use for application state and client-rendered UI. iconBrand: react --- @@ -16,8 +16,6 @@ There are some several members of this ecosystem that all work together to provi - [ReactJS](https://react.dev/) - A JavaScript UI library - [Redux](https://redux.js.org/) - A state manager for JavaScript -- [GraphQL](https://graphql.org/) - A query language for your API -- [Apollo Client](https://www.apollographql.com/apollo-client) - A framework for using GraphQL in your application All of these pillars of the frontend application can be customised, giving you more control over how the admin interface looks, feels, and behaves. @@ -102,65 +100,18 @@ and have the [Redux Devtools](https://github.com/zalmoxisus/redux-devtools-exten installed on Google Chrome or Firefox, which can be found by searching with your favourite search engine. -## GraphQL and apollo - -[GraphQL](https://graphql.org/learn/) is a strictly-typed query language that allows you to describe what data you want to fetch from your API. Because it is based on types, it is self-documenting and predictable. Further, it's structure lends itself nicely to fetching nested objects. Here is an example of a simple GraphQL query: - -```graphql -query GetUser($ID: Int!) { - user { - name - email - blogPosts { - title - comments(Limit: 5) { - author - comment - } - } - - } -} -``` - -The above query is almost self-descriptive. It gets a user by ID, returns his or her name and email address, along with the title of any blog posts he or she has written, and the first five comments for each of those. The result of that query is, very predictably, JSON that takes on the same structure. - -```json -{ - "user": { - "name": "Test user", - "email": "test@example.com", - "blogPosts": [ - { - "title": "How to be awesome at GraphQL", - "comments": [ - { - "author": "Uncle Cheese", - "comment": "Nice stuff, bro" - } - ] - } - ] - } -} -``` - -On its own, GraphQL offers nothing functional, as it's just a query language. You still need a service that will invoke queries and map their results to UI. For that, Silverstripe CMS uses an implementation of [Apollo Client](https://www.apollographql.com/docs/react/) that works with React. - ## For more information -This documentation will stop short of explaining React, Redux, and GraphQL/Apollo in-depth, as there is much better +This documentation will stop short of explaining React and Redux in-depth, as there is much better documentation available all over the web. We recommend: - [The Official React Tutorial](https://react.dev/learn) - [Build With React](https://buildwithreact.com/tutorial) - [Getting Started with Redux](https://egghead.io/courses/getting-started-with-redux) -- [The React Apollo docs](https://www.apollographql.com/docs/react/) -- [GraphQL in Silverstripe](/developer_guides/graphql/) ## Build tools and using Silverstripe CMS react components {#using-cms-react-components} -Silverstripe CMS includes react, redux, GraphQL, apollo, and many other thirdparty dependencies already, which are exposed using [webpack's expose-loader plugin](https://webpack.js.org/loaders/expose-loader/) for you to use as [webpack externals](https://webpack.js.org/configuration/externals/). +Silverstripe CMS includes react, redux, and many other thirdparty dependencies already, which are exposed using [webpack's expose-loader plugin](https://webpack.js.org/loaders/expose-loader/) for you to use as [webpack externals](https://webpack.js.org/configuration/externals/). There are also a lot of React components and other custom functionality (such as the injector, mentioned below) available for reuse. These are exposed in the same way. @@ -838,714 +789,6 @@ export default (originalReducer) => (getGlobalState) => (state, { type, payload }; ``` -## Using injector to customise GraphQL queries - -One of the strengths of GraphQL is that it allows us to declaratively state exactly what data a given component needs to function. Because GraphQL queries and mutations are considered primary concerns of a component, they are not abstracted away somewhere in peripheral asynchronous functions. Rather, they are co-located with the component definition itself. - -The downside of this is that, because queries are defined statically at compile time, they don't adapt well to the extension patterns that are inherent to Silverstripe CMS projects. For instance, a query for a [`Member`](api:SilverStripe\Security\Member) record may include fields for `FirstName` and `Email`, but if you have customised that class via extensions, and would like the component using that query to display your custom fields, your only option would be to override the entire query and the component with a custom implementation. In backend code, this would be tantamount to replacing the entire `Member` class and `SecurityAdmin` section just because you had a new field. You would never do that, right? It's an over-aggressive hack! We need APIs that make extension easy. - -To that end, the `Injector` library provides a container for abstract representations of GraphQL queries and mutations. You can register and transform them as you do components and reducers. They exist merely as abstract concepts until `Injector` loads, at which time all transformations are applied, and each registered query and mutation is composed and attached to their assigned components. - ### Extensions are only as good as the code they're extending -An important point to remember about these types of deep customisations is that they all depend heavily on the core code they're modifying to follow specific patterns. The more the core code makes use of `Injector` the easier it will be for third party developers to extend. Conversely, if the core is full of hard-coded component definitions and statically written queries, customisation will be at best less surgical and at worst, not possible. For this reason, we'll look at GraphQL customisations from two sides - making code extensible, and then extending that code. - -### Building an extensible GraphQL component - -Let's imagine that we have a module that adds a tab where the user can write "notes" about the content they are editing. We'll use GraphQL and React to render this UI. We have a dataobject called "Note" where we store these in the database. - -Here's what that might look like: - -```js -// my-module/client/src/components/Notes.js -import React from 'react'; -import gql from 'graphql-tag'; -import { graphql } from '@apollo/client/react/hoc'; - -export const Notes = ({ notes }) => ( - -); - -const getNotesQuery = gql` -query ReadNotes { - readNotes { - id - content - } -} -`; - -const apolloConfig = { - props({ data: { readNotes } }) { - return { - notes: readNotes || [] - }; - } -}; - -const NotesWithData = graphql(getNotesQuery, apolloConfig)(Notes); - -export default NotesWithData; -``` - -Next we'll expose the model to GraphQL: - -```yml -# my-module/_config/graphql.yml - -# Tell graphql that we're adding to the admin graphql schema -SilverStripe\GraphQL\Schema\Schema: - schemas: - admin: - src: - - my-module/_graphql -``` - -```yml -# my-module/_graphql/models.yml - -# Tell graphql how to scaffold the schema for our model -App\Model\Note: - fields: - id: true - content: true - operations: - read: - plugins: - paginateList: false -``` - -#### Define the app - -Finally, let's make a really simple container app which holds a header and our notes component, and inject it into the DOM using entwine. - -```js -// my-module/client/src/App.js -import React from 'react'; -import Notes from './components/Notes'; - -const App = () => ( -
-

Notes

- -
-); - -export default App; -``` - -```js -import { createRoot } from 'react-dom/client'; -import React from 'react'; -import { ApolloProvider } from '@apollo/client'; -import Injector from 'lib/Injector'; -import App from './App'; - -Injector.ready(() => { - const { apolloClient, store } = window.ss; - - // Assuming you've got some element in the DOM with the id "notes-app" - $('#notes-app').entwine({ - ReactRoot: null, - - onmatch() { - const root = createRoot(this[0]); - this.setReactRoot(root); - root.render( - - - - ); - }, - - onunmatch() { - const root = this.getReactRoot(); - if (root) { - root.unmount(); - this.setReactRoot(null); - } - }, - }); -}); -``` - -> [!NOTE] -> `this[0]` is how we get the underlying DOM element that the jQuery object is wrapping. We can't pass `this` directly into the `createRoot()` function because react doesn't know how to deal with a jQuery object wrapper. See [the jQuery documentation](https://api.jquery.com/Types/#jQuery) for more information about that syntax. - -The `silverstripe/admin` module provides `apolloClient` and `store` objects in the global namespace to be shared by other modules. We'll make use of those, and create our own app wrapped in ``. - -We register a callback with `Injector.ready()` because the `apolloClient` and `store` are ultimately coming from the injector, so we need to make sure those are ready before mounting our component. - -To mount the app, we use the `onmatch()` event fired by entwine, and we're off and running. Just don't forget to unmount the component in `onunmatch()`. - -What we've just built may work, but we've made life very difficult for other developers. They have no way of customising this. Let's change that. - -#### Register as much as possible with injector - -The best thing you can do to make your code extensible is to use `Injector` early and often. Anything that goes through Injector is easily customisable. - -First, let's break up the list into smaller components. - -```js -// my-module/client/src/components/NotesList.js -import React from 'react'; -import { inject } from 'lib/Injector'; - -const NotesList = ({ notes = [], ItemComponent }) => ( - -); - -// This tells the injector we want a component named "NotesListItem". -// We'll register our version of that component, and then other people can make -// any transformations that they like. -export default inject( - ['NotesListItem'], - // This second argument remaps the injected component name (NotesListItem) with our prop name - // (ItemComponent). If the prop is named the same as the injected name, we can ommit this second - // argument. - (NotesListItem) => ({ - ItemComponent: NotesListItem - }) -)(NotesList); -``` - -```js -// my-module/client/src/components/NotesListItem.js -import React from 'react'; - -const NotesListItem = ({ note }) =>
  • {note.content}
  • ; - -export default NotesListItem; -``` - -#### Creating an abstract query definition - -The next piece is the query. We'll need to register that with `Injector`. Unlike components and reducers, this is a lot more abstract. We're actually not going to write any GraphQL at all. We'll just build the concept of the query in an abstraction layer, and leave `Injector` to build the GraphQL syntax at runtime. - -```js -// my-module/client/src/state/readNotes.js -import { graphqlTemplates } from 'lib/Injector'; - -const { READ } = graphqlTemplates; - -const query = { - apolloConfig: { - props({ data: { readNotes } }) { - return { - notes: readNotes || [], - }; - } - }, - templateName: READ, - pluralName: 'Notes', - pagination: false, - params: {}, - fields: [ - 'id', - 'content', - ], -}; - -export default query; -``` - -Dynamic GraphQL queries are generated by populating pre-baked templates with specific pieces of data, including fields, fragments, variables, parameters, and more. By default, the templates available to you follow the GraphQL scaffolding API (`readMyObjects`, `readOneMyObject`, `createMyObject`, `deleteMyObject`, and `updateMyObject`). - -In this example, we're using the `READ` template, which needs to know the plural name of the object (e.g. `READ` with `Notes` makes a `readNotes` query), whether pagination is activated, and which fields you want to query. - -> [!TIP] -> For simplicity, we're not querying any relations or otherwise nested data here. If we had, for example, a `foo` relation with a `title` field and this was exposed in the schema, we would need to add it to the fields array like this: -> -> ```js -> const query = { -> // ... -> fields: [ -> 'foo', [ -> 'title', -> ] -> ], -> }; -> ``` -> -> You might instinctively try to use JSON object notation for this instead, but that won't work. - -#### Register all the things - -Let's now register all of this with Injector. - -```js -// my-module/client/src/boot/registerDependencies.js -import Injector, { injectGraphql } from 'lib/Injector'; -import NotesList from '../components/NotesList'; -import NotesListItem from '../components/NotesListItem'; -import readNotes from '../state/readNotes'; - -const registerDependencies = () => { - Injector.component.register('NotesList', NotesList); - Injector.component.register('NotesListItem', NotesListItem); - Injector.query.register('ReadNotes', readNotes); -}; - -export default registerDependencies; -``` - -> [!TIP] -> If you have a lot of components or queries to add, you can use `registerMany` instead: -> -> ```js -> Injector.component.registerMany({ -> NotesList, -> NotesListItem, -> // ...etc -> }); -> ``` - -We use `Injector.query.register()` to register our `readNotes` query so that other projects can extend it. - -#### Applying the injected query as a transformation - -The only missing piece now is to attach the `ReadNotes` injected query to the `NotesList` component. We could have done this using `injectGraphql` in the `NotesList` component itself, but instead, we'll do it as an Injector transformation. Why? There's a good chance whoever is customising the query will want to customise the UI of the component that is using that query. If someone adds a new field to a query, it is likely the component should display that new field. Registering the GraphQL injection as a transformation will allow a thirdparty developer to override the UI of the component explicitly *after* the GraphQL query is attached. This is important, because otherwise the customised component wouldn't use the query. - -```js -// my-module/client/src/boot/registerDependencies.js - -// ... -const registerDependencies = () => { - // ... - Injector.transform( - 'noteslist-graphql', - (updater) => { - updater.component('NotesList', injectGraphql('ReadNotes')); - } - ); -}; - -export default registerDependencies; -``` - -The transformation adds the higher-order component `injectGraphQL`, using the query we have just registered, `ReadNotes` as a dependency - basically, we're injecting the result of the query into the component. - -All of this feels like a lot of extra work, and, to be fair, it is. You're probably used to simply inlining one or many higher-order component compositions in your components. That works great when you're not concerned about making your components extensible, but if you want others to be able to customise your app, you really need to be sure to follow these steps. - -#### Update the app - -Our container app needs to have the `NotesList` component injected into it. - -```js -// my-module/client/src/App.js -import React from 'react'; -import { inject } from 'lib/Injector'; - -const App = ({ NotesList }) => ( -
    -

    Notes

    - -
    -); - -export default inject(['NotesList'])(App); -``` - -You can register the `App` component with `Injector`, too, but since it's already injected with dependencies it could get pretty convoluted. High level components like this are best left uncustomisable. - -#### Use the injector from an entwine context - -Since almost everything is in `Injector` now, we need to update our mounting logic to inject the dependencies into our app. - -```js -import { createRoot } from 'react-dom/client'; -import React from 'react'; -import { ApolloProvider } from '@apollo/client'; -import Injector, { provideInjector } from 'lib/Injector'; -import registerDependencies from './boot/registerDependencies'; -import App from './App'; - -registerDependencies(); - -Injector.ready(() => { - const { apolloClient, store } = window.ss; - const MyApp = () => ( - - - - ); - const MyAppWithInjector = provideInjector(MyApp); - - $('#notes-app').entwine({ - ReactRoot: null, - - onmatch() { - const root = createRoot(this[0]); - this.setReactRoot(root); - root.render(); - }, - - onunmatch() { - const root = this.getReactRoot(); - if (root) { - root.unmount(); - this.setReactRoot(null); - } - }, - }); -}); -``` - -The callback we register with `Injector.ready()` is even more important now - it ensures that we don't attempt to render anything before the transformations have been applied, which would result in fatal errors. - -We then make our app `Injector` aware by wrapping it with the `provideInjector` higher-order component. - -### Extending an existing GraphQL app - -Let's suppose we have a project that extends the `Notes` object in some way. Perhaps we have a `Priority` field whose value alters the UI in some way. Thanks to a module developer who gave use plenty of extension points through `Injector`, this will be pretty easy. - -#### Applying the extensions - -We'll first need to apply the extension and update our GraphQL scaffolding. - -```yml -# app/_config/extensions.yml -App\Model\Note: - extensions: - # this extension adds a "Priority" integer field - - MyOtherApp\Extension\NoteExtension -``` - -Remember, this example is in a project which is customising the schema from the previous example, so we still have to tell GraphQL where to find our schema modifications. - -If you're following along, you could declare a different folder than before within the same project so you can see how the schema definitions merge together into a single schema. - -```yml -# app/_config/graphql.yml -SilverStripe\GraphQL\Schema\Schema: - schemas: - admin: - src: - - app/_graphql -``` - -```yml -# app/_graphql/models.yml -App\Model\Note: - fields: - priority: true -``` - -#### Creating transforms - -Let's first update the `NotesListItem` to contain our new field. - -> [!WARNING] -> Note that we're overriding the entire `NotesListItem` component. This is the main reason we broke the original list up into smaller components. - -```js -// app/client/src/transformNotesListItem.js -import React from 'react'; - -const transformNotesListItem = () => ({ note: { content, priority } }) => ( -
  • {content} [PRIORITY: {['Low', 'Medium', 'High'][priority]}]
  • -); - -export default transformNotesListItem; -``` - -Now, let's update the query to fetch our new field. - -```js -// app/client/src/transformReadNotes.js -const transformReadNotes = (manager) => { - manager.addField('priority'); -}; - -export default transformReadNotes; -``` - -Simple! The transformation passes us a `ApolloGraphQLManager` instance that provides a fluent API for updating a query definition the same way the `FormStateManager` allows us to update Redux form state. - -#### Adding fields - -In the above example, we added a single field to a query. Here's how that works: - -```js -manager.addField(fieldName, fieldPath = 'root'); -``` - -The `fieldPath` argument tells the manager at what level to add the field. In this case, since the `priority` field is going on the root query (`readNotes`), we'll use `root` as the path. But suppose we had a more complex query like this: - -```graphql -query readMembers { - firstName - surname - friends { - email - company { - name - } - } -} -``` - -If we wanted to add a field to the nested `company` query on `friends`, we would use a path syntax. - -```js -manager.addField('tagline', 'root/friends/company'); -``` - -#### Adding field arguments - -Let's suppose we had the following query: - -```graphql -query ReadMembers($imageSize: String!) { - readMembers { - firstName - avatar(size: $imageSize) - company { - name - } - } -} -``` - -Maybe the `company` type has a `logo`, and we want to apply the `imageSize` parameter as an argument to that field. - -```js -manager.addArg('size', 'imageSize', 'root/company/logo'); -``` - -Where `root/company/logo` is the path to the field, `size` is the name of the argument on that field, and `imageSize` is the name of the variable. - -#### Applying the transforms - -Now, let's apply all these transformations, and we'll use the `after` property to ensure they get applied in the correct sequence. - -```js -// app/client/src/boot.js -import Injector from 'lib/Injector'; -import transformNotesListItem from './transformNotesListItem'; -import transformReadNotes from './transformReadNotes'; - -Injector.transform( - 'noteslist-query-extension', - (updater) => { - updater.component('NotesListItem', transformNotesListItem); - updater.query('ReadNotes', transformReadNotes); - }, - { after: 'noteslist-graphql' } -); -``` - -> [!TIP] -> This transformation could either be transpiled as-is, or if you have other JavaScript to include in this module you might want to export it as a function and call it from some entry point. -> Don't forget to add the transpiled result to the CMS e.g. via the `SilverStripe\Admin\LeftAndMain.extra_requirements_javascript` configuration property. - -### Creating extensible mutations - -Going back to the original module, let's add an `AddForm` component to our list that lets the user create a new note. - -```js -// my-module/client/src/components/AddForm.js -import React, { useRef } from 'react'; - -const AddForm = ({ onAdd }) => { - const inputRef = useRef(null); - return ( -
    - - - -
    - ); -}; - -export default AddForm; -``` - -> [!NOTE] -> Because this isn't a full react tutorial, we've avoided the complexity of ensuring the list gets updated when we add an item to the form. You'll have to refresh the page to see your note after adding it. - -And we'll inject that component into our `App` container. - -```js -// my-module/client/src/App.js -import React from 'react'; -import { inject } from 'lib/Injector'; - -const App = ({ NotesList, NoteAddForm }) => ( -
    -

    Notes

    - - -
    -); - -export default inject(['NotesList', 'NoteAddForm'])(App); -``` - -Next, add a mutation template to attach to the form. - -```js -// my-module/client/src/state/createNote.js -import { graphqlTemplates } from 'lib/Injector'; - -const { CREATE } = graphqlTemplates; -const mutation = { - apolloConfig: { - props({ mutate }) { - return { - onAdd: (content) => { - mutate({ - variables: { - input: { - content, - } - } - }); - } - }; - } - }, - templateName: CREATE, - singularName: 'Note', - pagination: false, - fields: [ - 'content', - 'id' - ], -}; - -export default mutation; -``` - -It looks like a lot of code, but if you're familiar with Apollo mutations, this is pretty standard. The supplied `mutate()` function gets mapped to a prop - in this case `onAdd`, which the `AddForm` component is configured to invoke. We've also supplied the `singularName` as well as the template `CREATE` for the `createNote` scaffolded mutation. - -And make sure we're exposing the mutation in our GraphQL schema: - -```yml -# my-module/_graphql/models.yml -App\Model\Note: - #... - operations: - #... - create: true -``` - -Lastly, let's just register all this with `Injector`. - -```js -// my-module/client/src/boot/registerDependencies.js -import AddForm from '../components/AddForm'; -import createNote from '../state/createNote'; -// ... - -const registerDependencies = () => { - // ... - Injector.component.register('NoteAddForm', AddForm); - Injector.query.register('CreateNote', createNote); - - // ... - Injector.transform( - 'notesaddform-graphql', - (updater) => { - updater.component('NoteAddForm', injectGraphql('CreateNote')); - } - ); -}; - -export default registerDependencies; -``` - -This is exactly the same pattern as we did before with a query, only with different components and GraphQL abstractions this time. Note that even though `CreateNote` is a mutation, it still gets registered under `Injector.query` for simplicity. - -### Extending mutations - -Now let's switch back to the project where we're customising the Notes application. The developer is going to want to ensure that users can supply a "Priority" value for each note entered. This will involve updating the `AddForm` component. - -```js -// app/client/src/transformAddForm.js -import React, { useRef } from 'react'; - -const transformAddForm = () => ({ onAdd }) => { - const contentRef = useRef(null); - const priorityRef = useRef(null); - return ( -
    - - - - - -
    - ); -}; - -export default transformAddForm; -``` - -We're now passing two arguments to the `onAdd` callback - one for the note content, and another for the priority. We'll need to update the mutation to reflect this. - -```js -// app/client/src/transformCreateNote.js -const transformCreateNote = (manager) => { - manager.addField('priority'); - manager.transformApolloConfig('props', ({ mutate }) => (prevProps) => { - const onAdd = (content, priority) => { - mutate({ - variables: { - input: { - // Don't forget to keep the content variable in here! - content, - priority, - } - } - }); - }; - - return { - ...prevProps, - onAdd, - }; - }); -}; - -export default transformCreateNote; -``` - -All we've done here is overridden the `props` setting in the `CreateNote` apollo config. Recall from the previous section that it maps the `mutate` function to the `onAdd` prop. Since we've changed the signature of that function, we need to override the entire prop. - -Now we just need to register these transforms, and we're done! - -```js -// app/client/src/boot.js -import transformAddForm from './transformAddForm'; -import transformCreateNote from './transformCreateNote'; -// ... - -Injector.transform( - 'noteslist-query-extension', - (updater) => { - // ... - updater.component('NoteAddForm', transformAddForm); - updater.query('CreateNote', transformCreateNote); - }, - { after: ['noteslist-graphql', 'notesaddform-graphql'] } -); -``` +An important point to remember about these types of deep customisations is that they all depend heavily on the core code they're modifying to follow specific patterns. The more the core code makes use of `Injector` the easier it will be for third party developers to extend. Conversely, if the core is full of hard-coded component definitions and statically written queries, customisation will be at best less surgical and at worst, not possible. diff --git a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/CMS_Alternating_Button.md b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/CMS_Alternating_Button.md index 07fb57b3f..377e54dd0 100644 --- a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/CMS_Alternating_Button.md +++ b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/CMS_Alternating_Button.md @@ -93,7 +93,7 @@ class MyObject extends DataObject > [!WARNING] > The following documentation regarding jQuery, jQueryUI and Entwine does not apply to React components or sections powered by React. > If you're developing new functionality in React powered sections please refer to -> [React, Redux, and GraphQL](/developer_guides/customising_the_admin_interface/reactjs_redux_and_graphql/). +> [React and Redux](/developer_guides/customising_the_admin_interface/reactjs_and_redux/). As with the *Save* and *Save & publish* buttons, you might want to add some scripted reactions to user actions on the frontend. You can affect the state of the button through the jQuery UI calls. diff --git a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/Customise_React_Components.md b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/Customise_React_Components.md index 4124ddc63..a34c8271b 100644 --- a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/Customise_React_Components.md +++ b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/Customise_React_Components.md @@ -11,7 +11,7 @@ In this tutorial, we'll customise some form elements rendered with React to have Let's add a character count to the `TextField` component. `TextField` is a built-in component in the admin area. Because the `TextField` component is fetched through Injector, we can override it and augment it with our own functionality. -First, let's create our [higher order component](../reactjs_redux_and_graphql#customising-react-components-with-injector). +First, let's create our [higher order component](../reactjs_and_redux#customising-react-components-with-injector). ```js // my-module/js/components/CharacterCounter.js @@ -27,7 +27,7 @@ const CharacterCounter = (TextField) => (props) => ( export default CharacterCounter; ``` -Now let's add this higher order component to [Injector](../reactjs_redux_and_graphql#the-injector-api). +Now let's add this higher order component to [Injector](../reactjs_and_redux#the-injector-api). ```js // my-module/js/main.js diff --git a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/Extend_CMS_Interface.md b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/Extend_CMS_Interface.md index d5667f501..d981616ad 100644 --- a/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/Extend_CMS_Interface.md +++ b/en/02_Developer_Guides/15_Customising_the_Admin_Interface/How_Tos/Extend_CMS_Interface.md @@ -251,8 +251,8 @@ how-to. ## React-rendered UI -For sections of the admin that are rendered with React, Redux, and GraphQL, please refer -to [the introduction on those concepts](../reactjs_redux_and_graphql/), +For sections of the admin that are rendered with React and Redux, please refer +to [the introduction on those concepts](../reactjs_and_redux/), as well as their respective How-To's in this section. ### Implementing handlers diff --git a/en/02_Developer_Guides/19_GraphQL/01_getting_started/01_activating_the_server.md b/en/02_Developer_Guides/19_GraphQL/01_getting_started/01_activating_the_server.md deleted file mode 100644 index 6ebdfefe8..000000000 --- a/en/02_Developer_Guides/19_GraphQL/01_getting_started/01_activating_the_server.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Activating the default server -summary: Open up the default server that comes pre-configured with the module -icon: rocket ---- - -# Getting started - -[CHILDREN asList] - -## Activating the default GraphQL server - -GraphQL is used through a single route, typically `/graphql`. You need -to define *types* and *queries* to expose your data via this endpoint. While this recommended -route is left open for you to configure on your own, the modules contained in the [CMS recipe](https://github.com/silverstripe/recipe-cms), -(e.g. `silverstripe/asset-admin`) run off a separate GraphQL server with its own endpoint -(`admin/graphql`) with its own permissions and schema. - -These separate endpoints have their own identifiers. `default` refers to the GraphQL server -in the user space (e.g. `/graphql`) - i.e. your custom schema, while `admin` refers to the -GraphQL server used by CMS modules (`admin/graphql`). You can also [set up a new schema server](#setting-up-a-custom-graphql-server) -if you wish. - -> [!NOTE] -> The word "server" here refers to a route with its own isolated GraphQL schema. It does -> not refer to a web server. - -By default, `silverstripe/graphql` does not route any GraphQL servers. To activate the default, -public-facing GraphQL server that ships with the module, just add a rule to [`Director`](api:SilverStripe\Control\Director). - -```yml -SilverStripe\Control\Director: - rules: - 'graphql': '%$SilverStripe\GraphQL\Controller.default' -``` - -## Setting up a custom GraphQL server - -In addition to the default `/graphql` endpoint provided by this module by default, -along with the `admin/graphql` endpoint provided by the CMS modules (if they're installed), -you may want to set up another GraphQL server running on the same installation of Silverstripe CMS. - -Let's set up a new controller to handle the requests. - -```yml -SilverStripe\Core\Injector\Injector: - # ... - SilverStripe\GraphQL\Controller.myNewSchema: - class: SilverStripe\GraphQL\Controller - constructor: - schemaKey: myNewSchema -``` - -We'll now need to route the controller. - -```yml -SilverStripe\Control\Director: - rules: - 'my-graphql': '%$SilverStripe\GraphQL\Controller.myNewSchema' -``` - -Now, once you have [configured](configuring_your_schema) and [built](building_the_schema) your schema, you -can access it at `/my-graphql`. - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/01_getting_started/02_configuring_your_schema.md b/en/02_Developer_Guides/19_GraphQL/01_getting_started/02_configuring_your_schema.md deleted file mode 100644 index 7af140cec..000000000 --- a/en/02_Developer_Guides/19_GraphQL/01_getting_started/02_configuring_your_schema.md +++ /dev/null @@ -1,309 +0,0 @@ ---- -title: Configuring your schema -summary: Add a basic type to the schema configuration -icon: code ---- - -# Getting started - -[CHILDREN asList] - -## Configuring your schema - -GraphQL is a strongly-typed API layer, so having a schema behind it is essential. Simply put: - -- A schema consists of **[types](https://graphql.org/learn/schema/#type-system)** -- **Types** consist of **[fields](https://graphql.org/learn/queries/#fields)** -- **Fields** can have **[arguments](https://graphql.org/learn/queries/#arguments)**. -- **Fields** need to be **[resolved](https://graphql.org/learn/execution/#root-fields-resolvers)** - -**Queries** are just **fields** on a type called "query". They can take arguments, and they -must be resolved. - -There's a bit more to it than that, and if you want to learn more about GraphQL, you can read -the [full documentation](https://graphql.org/learn/), but for now, these three concepts will -serve almost all of your needs to get started. - -> [!TIP] -> It is strongly recommended that you install the [GraphQL devtools](https://github.com/silverstripe/silverstripe-graphql-devtools) module to help with testing your API. -> -> Included in that module is a `GraphQLSchemaInitTask` task to initialise a basic GraphQL schema to get you started. Instructions for using the task are included in the module's README.md. - -### Initial setup - -To start your first schema, open a new configuration file. Let's call it `graphql.yml`. - -```yml -# app/_config/graphql.yml -SilverStripe\GraphQL\Schema\Schema: - schemas: - # your schemas here -``` - -Let's populate a schema that is pre-configured for us out of the box called "default". - -```yml -# app/_config/graphql.yml -SilverStripe\GraphQL\Schema\Schema: - schemas: - default: - config: - # general schema config here - types: - # your generic types here - models: - # your DataObjects here - queries: - # your queries here - mutations: - # your mutations here - enums: - # your enums here -``` - -### Avoid config flushes - -Because the schema definition is only consumed at build time and never used at runtime, it doesn't -make much sense to store it in the configuration layer, because it just means you'll -have to `flush=1` every time you make a schema update, which will slow down your builds. - -It is recommended that you store your schema YAML **outside of the _config directory** to -increase performance and remove the need for flushing when you [build your schema](building_the_schema). - -> [!WARNING] -> This doesn't mean there is never a need to `flush=1` when building your schema. If you were to add a new -> schema, make a change to the value of this `src` attribute, or create new PHP classes, those are still -> standard config changes which won't take effect without a flush. - -We can do this by adding a `src` key to our `app/_config/graphql.yml` schema definition -that maps to a directory relative to the project root. - -```yml -# app/_config/graphql.yml -SilverStripe\GraphQL\Schema\Schema: - schemas: - default: - src: - - app/_graphql -``` - -Your `src` must be an array. This allows further source files to be merged into your schema. -This feature can be use to extend the schema of third party modules. - -> [!NOTE] -> Your directory can also be relative to a module reference, e.g. `somevendor/somemodule: _graphql`: -> -> ```yml -> # app/_config/graphql.yml -> SilverStripe\GraphQL\Schema\Schema: -> schemas: -> default: -> src: -> - app/_graphql -> - module/_graphql -> # The next line would map to `vendor/somevendor/somemodule/_graphql` -> - 'somevendor/somemodule: _graphql' -> ``` - -Now, in the new `app/_graphql` folder, we can create YAML file definitions. - -```yml -# app/_graphql/schema.yml - -# no schema key needed. it's implied! -config: - # your schema config here -types: - # your generic types here -models: - # your DataObjects here -bulkLoad: - # your bulk loader directives here -queries: - # your queries here -mutations: - # your mutations here -enums: - # your enums here -``` - -#### Namespacing your schema files - -Your schema YAML file will get quite bloated if it's just used as a monolithic source of truth -like this. We can tidy this up quite a bit by simply placing the files in directories that map -to the keys they populate -- e.g. `config/`, `types/`, `models/`, `queries/`, `mutations/`, etc. - -There are two approaches to namespacing: - -- By filename -- By directory name - -##### Namespacing by filename - -If the filename is named one of the seven keywords used in the `app/_graphql/schema.yml` example above, it will be implicitly placed -in the corresponding section of the schema - e.g. any configuration -added to `app/_graphql/config.yml` will be implicitly added to -`SilverStripe\GraphQL\Schema\Schema.schemas.default.config`. - -**This only works in the root source directory** (i.e. `app/_graphql/some-directory/config.yml` -will not work). - -```yml -# app/_graphql/config.yml - -# my config here -``` - -```yml -# app/_graphql/types.yml - -# my types here -``` - -```yml -# app/_graphql/models.yml - -# my models here -``` - -```yml -# app/_graphql/enums.yml - -# my enums here -``` - -```yml -# app/_graphql/bulkLoad.yml - -# my bulk loader directives here -``` - -##### Namespacing by directory name - -If you use a parent directory name (at any depth) of one of the seven keywords, it will -be implicitly placed in the corresponding section of the schema - e.g. any configuration -added to a `.yml` file in `app/_graphql/config/` will be implicitly added to -`SilverStripe\GraphQL\Schema\Schema.schemas.default.config`. - -> [!TIP] -> The names of the actual files here do not matter. You could for example have a separate file -> for each of your types, e.g. `app/_graphql/types/my-first-type.yml`. - -```yml -# app/_graphql/config/config.yml - -# my config here -``` - -```yml -# app/_graphql/types/types.yml - -# my types here -``` - -```yml -# app/_graphql/models/models.yml - -# my models here -``` - -```yml -# app/_graphql/enums/enums.yml - -# my enums here -``` - -```yml -# app/_graphql/bulkLoad/bulkLoad.yml - -# my bulk loader directives here -``` - -##### Going even more granular - -These special directories can contain multiple files that will all merge together, so you can even -create one file per type, or some other convention. All that matters is that the parent directory name -*or* the filename matches one of the schema keys. - -The following are perfectly valid: - -- `app/_graphql/config/config.yml` maps to `SilverStripe\GraphQL\Schema\Schema.schemas.default.config` -- `app/_graphql/types/allElementalBlocks.yml` maps to `SilverStripe\GraphQL\Schema\Schema.schemas.default.types` -- `app/_graphql/news-and-blog/models/blog.yml` maps to `SilverStripe\GraphQL\Schema\Schema.schemas.default.models` -- `app/_graphql/mySchema.yml` maps to `SilverStripe\GraphQL\Schema\Schema.schemas.default` - -### Schema config - -Each schema can declare a generic configuration section, `config`. This is mostly used for assigning -or removing plugins and resolvers. - -An important subsection of `config` is `modelConfig`, where you can configure settings for specific -models, e.g. `DataObject`. - -Like the other sections, it can have its own `config.yml`, or just be added as a `config:` -mapping to a generic schema YAML document. - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - plugins: - inheritance: true - operations: - read: - plugins: - readVersion: false - paginateList: false -``` - -You can learn more about plugins and resolvers in the [query plugins](../working_with_dataobjects/query_plugins), -[plugins](../plugins), [building a custom query](../working_with_generic_types/building_a_custom_query#building-a-custom-query), -and [resolver discovery](../working_with_generic_types/resolver_discovery) sections. - -### Defining a basic type - -Let's define a generic type for our GraphQL schema. - -> [!NOTE] -> Generic types don't map to `DataObject` classes - they're useful for querying more 'generic' data (hence the name). -> You'll learn more about adding DataObjects in [working with DataObjects](../working_with_DataObjects). - -```yml -# app/_graphql/types.yml - -Country: - fields: - name: String - code: String - population: Int - languages: '[String]' -``` - -If you're familiar with [GraphQL type language](https://graphql.org/learn/schema/#type-language), -this should look pretty familiar. - -There are only a handful of [scalar types](https://graphql.org/learn/schema/#scalar-types) -available in GraphQL by default. They are: - -- String -- Int -- Float -- Boolean - -To define a type as a list, you wrap it in brackets: `[String]`, `[Int]` - -To define a type as required (non-null), you add an exclamation mark: `String!` - -Often times, you may want to do both: `[String!]!` - -> [!WARNING] -> Look out for the footgun, here. Make sure your bracketed type is in quotes -> (i.e. `'[String]'`, not `[String]`), otherwise it's valid YAML that will get parsed as an array! - -That's all there is to it! To learn how we can take this further, check out the -[working with generic types](../working_with_generic_types) documentation. Otherwise, -let's get started on [**adding some DataObjects**](../working_with_DataObjects). - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/01_getting_started/03_building_the_schema.md b/en/02_Developer_Guides/19_GraphQL/01_getting_started/03_building_the_schema.md deleted file mode 100644 index f2912618a..000000000 --- a/en/02_Developer_Guides/19_GraphQL/01_getting_started/03_building_the_schema.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -title: Building the schema -summary: Turn your schema configuration into executable code -icon: hammer ---- - -# Getting started - -[CHILDREN asList] - -## Building the schema - -The primary API surface of the `silverstripe/graphql` module is the YAML configuration, along -with some [procedural configuration](using_procedural_code). It is important to understand -that **none of this configuration gets interpreted at runtime**. Loading the schema configuration -(which we refer to as the "schema definition") at runtime and converting it to executable code -has dire effects on performance, making API requests slower and slower as the schema grows larger. - -To mitigate this problem, the schema that gets executed at runtime is **generated PHP code**. -This code generation happens during a build step, and it is critical to run this build step -whenever the schema definition changes, or a new schema definition is added. - -### What triggers a GraphQL code build? - -- Any time you run the `dev/graphql/build` command to explicitly build your GraphQL schemas. -- Any time you run the `dev/build` command on your project. -- `silverstripe/graphql` will attempt to generate your schema "on-demand" on the first GraphQL request *only* if it wasn’t already generated. - -> [!WARNING] -> Relying on the "on-demand" schema generation on the first GraphQL request requires some additional consideration. -> See [deploying the schema](deploying_the_schema#on-demand). - -#### Running `dev/graphql/build` - -The main command for generating the schema code is `dev/graphql/build`. - -`vendor/bin/sake dev/graphql/build` - -This command takes an optional `schema` parameter. If you only want to generate a specific schema -(e.g. generate your custom schema, but not the CMS schema), you should pass in the name of the -schema you want to build. - -> [!NOTE] -> If you do not provide a `schema` parameter, the command will build all schemas. - -`vendor/bin/sake dev/graphql/build schema=default` - -> [!NOTE] -> Most of the time, the name of your custom schema is `default`. If you're editing DataObjects -> that are accessed with GraphQL in the CMS, you may have to rebuild the `admin` schema as well. - -Keep in mind that some of your changes will be in YAML in the `_config/` directory, which also -requires a flush. - -`vendor/bin/sake dev/graphql/build schema=default flush=1` - -#### Building on dev/build - -By default, all schemas will be built during `dev/build`. To disable this, change the config: - -```yml -SilverStripe\GraphQL\Extensions\DevBuildExtension: - enabled: false -``` - -### Caching - -Generating code is a pretty expensive process. A large schema with 50 `DataObject` classes exposing -all their operations can take up to **20 seconds** to generate. This may be acceptable -for initial builds and deployments, but during incremental development this can really -slow things down. - -To mitigate this, the generated code for each type is cached against a signature. -If the type hasn't changed, it doesn't get re-built. This reduces build times to **under one second** for incremental changes. - -#### Clearing the schema cache - -If you want to completely re-generate your schema from scratch, you can add `clear=1` to the `dev/graphql/build` command. - -`vendor/bin/sake dev/graphql/build schema=default clear=1` - -If your schema is producing unexpected results, try using `clear=1` to eliminate the possibility -of a caching issue. If the issue is resolved, record exactly what you changed and [create an issue](https://github.com/silverstripe/silverstripe-graphql/issues/new). - -### Build gotchas - -Keep in mind that it's not always explicit schema definition changes that require a build. -Anything influencing the output of the schema will require a build. This could include -tangential changes such as: - -- Updating the `$db` array (or relationships) of a `DataObject` class that has `fields: '*'` (i.e. include all fields on that class in the schema). -- Adding a new resolver for a type that uses [resolver discovery](../working_with_generic_types/resolver_discovery) -- Adding an extension to a `DataObject` class -- Adding a new subclass of a `DataObject` class that is already exposed - -### Viewing the generated code - -By default, the generated PHP code is placed in the `.graphql-generated/` directory in the root of your project. -It is not meant to be accessible through your webserver, Which is ensured by keeping it outside of the -`public/` webroot and the inclusion of a `.htaccess` file in each schema folder. - -Additional files are generated for CMS operation in `public/_graphql/`, and -those *are* meant to be accessible through your webserver. -See [Tips and Tricks: Schema Introspection](tips_and_tricks#schema-introspection) -to find out how to generate these files for your own schema. - -> [!CAUTION] -> While it is safe for you to view these files, you should not manually alter them. If you need to make a change -> to your GraphQL schema, you should [update the schema definition](configuring_your_schema) and rebuild your schema. - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/01_getting_started/04_using_procedural_code.md b/en/02_Developer_Guides/19_GraphQL/01_getting_started/04_using_procedural_code.md deleted file mode 100644 index 1e356f252..000000000 --- a/en/02_Developer_Guides/19_GraphQL/01_getting_started/04_using_procedural_code.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -title: Building a schema with procedural code -summary: Use PHP code to build your schema -icon: tools ---- - -# Getting started - -[CHILDREN asList] - -## Building a schema with procedural code - -Sometimes you need access to dynamic information to populate your schema. For instance, you -may have an enum containing a list of all the languages that are configured for the website. It -wouldn't make sense to build this statically. It makes more sense to have a single source -of truth. - -Internally, model-driven types that conform to the shapes of their models must use procedural -code to add fields, create operations, and more, because the entire premise of model-driven -types is that they're dynamic. So the procedural API for schemas has to be pretty robust. - -Lastly, if you just prefer writing PHP to writing YAML, this is a good option, too. - -> [!WARNING] -> One thing you cannot do with the procedural API, though it may be tempting, is define resolvers -> on the fly as closures. Resolvers must be static methods on a class, and are evaluated during -> the schema build. - -### Adding executable code - -We can use the `execute` section of the config to add an implementation of [`SchemaUpdater`](api:SilverStripe\GraphQL\Schema\Interfaces\SchemaUpdater). - -```yml -SilverStripe\GraphQL\Schema\Schema: - schemas: - default: - config: - execute: - - 'App\GraphQL\MySchema' -``` - -Now just implement the [`SchemaUpdater`](api:SilverStripe\GraphQL\Schema\Interfaces\SchemaUpdater) interface. - -```php -// app/src/GraphQL/MySchema.php -namespace App\GraphQL; - -use SilverStripe\GraphQL\Schema\Interfaces\SchemaUpdater; -use SilverStripe\GraphQL\Schema\Schema; - -class MySchema implements SchemaUpdater -{ - public static function updateSchema(Schema $schema): void - { - // update here - } -} -``` - -### Example code - -Most of the API should be self-documenting, and a good IDE should autocomplete everything you -need, but the key methods map directly to their configuration counterparts: - -- types (`$schema->addType(Type $type)`) -- models (`$schema->addModel(ModelType $type)`) -- queries (`$schema->addQuery(Query $query)`) -- mutations (`$schema->addMutation(Mutation $mutation)`) -- enums (`$schema->addEnum(Enum $type)`) -- interfaces (`$schema->addInterface(InterfaceType $type)`) -- unions (`$schema->addUnion(UnionType $type)`) - -```php -namespace App\GraphQL; - -use App\Model\MyDataObject; -use SilverStripe\GraphQL\Schema\Field\Query; -use SilverStripe\GraphQL\Schema\Interfaces\SchemaUpdater; -use SilverStripe\GraphQL\Schema\Schema; -use SilverStripe\GraphQL\Schema\Type\Type; - -class MySchema implements SchemaUpdater -{ - public static function updateSchema(Schema $schema): void - { - $countryType = Type::create('Country') - ->addField('name', 'String') - ->addField('code', 'String'); - $schema->addType($countryType); - - $countriesQuery = Query::create('readCountries', '[Country]!') - ->addArg('limit', 'Int'); - $schema->addQuery($countriesQuery); - - $myModel = $schema->createModel(MyDataObject::class) - ->addAllFields() - ->addAllOperations(); - $schema->addModel($myModel); - } -} -``` - -#### Chainable setters - -To make your code chainable, when adding fields and arguments, you can invoke a callback -to update it on the fly. - -```php -$countryType = Type::create('Country') - ->addField('name', 'String', function (Field $field) { - // Must be a callable. No inline closures allowed! - $field->setResolver([MyResolverClass::class, 'countryResolver']) - ->addArg('myArg', 'String!'); - }) - ->addField('code', 'String'); -$schema->addType($countryType); - -$countriesQuery = Query::create('readCountries', '[Country]!') - ->addArg('limit', 'Int', function (Argument $arg) { - $arg->setDefaultValue(20); - }); -$schema->addQuery($countriesQuery); -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/01_getting_started/05_deploying_the_schema.md b/en/02_Developer_Guides/19_GraphQL/01_getting_started/05_deploying_the_schema.md deleted file mode 100644 index 5c56acae9..000000000 --- a/en/02_Developer_Guides/19_GraphQL/01_getting_started/05_deploying_the_schema.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: Deploying the schema -summary: Deploy your GraphQL schema to a test or production environment -icon: rocket ---- - -# Getting started - -[CHILDREN asList] - -## Deploying the schema - -One way or another, you must get the `.graphql-generated/` and `public/_graphql/` folders into your test and production environments for Silverstripe CMS (and your own custom queries) to work as expected. There are many ways to do so. The options below are listed in order of complexity. - -### Single-server hosting solutions with simple deployments {#simple-single-server} - -If you host your site on a single server and you always run `dev/build` during the deployment, then assuming you have set up permissions to allow the webserver to write to the `.graphql-generated/` and `public/_graphql/` folders, your GraphQL schema will be built for you as a side-effect of running `dev/build`. You don't need to do anything further. Note that if your schema is exceptionally large you may still want to read through the rest of the options below. - -### Options for any hosting solution - -#### Commit the schema to version control {#commit-to-vcs} - -A simplistic approach is to build the schema in your local development environment and add the `.graphql-generated/` and `public/_graphql/` folders to your version control system. With this approach you would most likely want to disable schema generation at `dev/build`. - -This approach has the advantage of being very simple, but it will pollute your commits with massive diffs for the generated code. - -> [!WARNING] -> Make sure you set your site to `live` mode and remove any `DEBUG_SCHEMA=1` from your `.env` file if it is there before generating the schema to be committed. - -#### Explicitly build the schema during each deployment {#build-during-deployment} - -Many projects will automatically run a `dev/build` whenever they deploy a site to their production environment. If that’s your case, then you can just let this process run normally and generate the `.graphql-generated/` and `public/_graphql/` folders for you. This will allow you to add these folders to your `.gitignore` file and avoid tracking the folder in your version control system. - -Be aware that for this approach to work, the process executing the `dev/build` must have write access to create the folders (or you must create those folders yourself, and give write access for those folders specifically), and for multi-server environments a `dev/build` or `dev/graphql/build` must be executed on each server hosting your site after each deployment. - -#### Use a CI/CD pipeline to build your schema {#using-ci-cd} - -Projects with more sophisticated requirements or bigger schemas exposing more than 100 `DataObject` classes may want to consider using a continuous-integration/continuous-deployment (CI/CD) pipeline to build their GraphQL schema. - -In this kind of setup, you would need to update your deployment script to run the `dev/graphql/build` command which builds the `.graphql-generated/` and `public/_graphql/` folders. In multi-server environments this must be executed on each server hosting your site. - -### Multi-server hosting solutions {#multi-server} - -If your site is hosted in an environment with multiple servers or configured to auto-scale with demand, there are some additional considerations. For example if you only generate the schema on one single server (i.e. via `dev/build` or `dev/graphql/build`), then the other servers won’t have a `.graphql-generated/` or `public/_graphql/` folder (or those folders will be empty if you manually created them). - -#### Rely on "on-demand" schema generation on the first GraphQL request {#on-demand} - -When the first GraphQL schema request occurs, `silverstripe/graphql` will attempt to build the `.graphql-generated/` and `public/_graphql/` folders "on-demand" if they're not already present on the server. Similarly, if the folders are present but empty, it will build the schema "on-demand". This will impose a one-time performance hit on the first GraphQL request. If your project defines multiple schemas, only the schema that is being accessed will be generated. - -For most common use cases, this process is relatively fast. For example, the GraphQL schema that is used to power the CMS can be built in about a quarter of a second. - -While benchmarking schema generation performance, we measured that a schema exposing 180 DataObjects with 1600 relations could be built on-demand in less than 6 seconds on a small AWS instance. - -Our expectation is that on-demand schema generation will be performant for most projects with small or medium schemas. - -> [!WARNING] -> Note that with this approach you will need to remove or empty the `.graphql-generated/` and `public/_graphql/` folders on each server for each deployment that includes a change to the schema definition, or you risk having an outdated GraphQL schema. The "on-demand" schema generation does not detect changes to the schema definition. - -#### Build the schema during/before deployment and share it across your servers {#multi-server-shared-dirs} - -If you have a particularly large schema, you may want to ensure it is always built before the first GraphQL request. It might make sense for you to sync your `.graphql-generated/` and `public/_graphql/` folders across all your servers using an EFS or similar mechanism. In that case you only need to run `dev/build` or `dev/graphql/build` on the server with the original folder - but bear in mind that this may have a performance impact. - -### Performance considerations when building the GraphQL schema {#performance-considerations} - -The main driver in the resources it takes to build a GraphQL schema is the number DataObjects and the number of exposed relations in that schema. In most cases, not all DataObjects in your database will be included in your schema - best practice is to only add classes to your schema definition if you will need to query them. DataObjects not included in your schema will not impact the time or memory needed to build it. - -Silverstripe CMS defines an "admin" schema it uses for its own purpose. This schema is relatively small and has a negligible performance impact. - -As an indication, benchmarks were run on a t3.micro AWS instance. The results are in the table below. These numbers may not be representative of the performance in your own environment. If you intend to build large GraphQL schemas, you should take the time to run your own benchmarks and adjust your deployment strategy accordingly. - -DataObjects in schema | Build time (ms) | Memory use (MB) --- | -- | -- -5 | 290 | 26 -10 | 310 | 26 -40 | 1060 | 38 -100 | 2160 | 58 -250 | 5070 | 114 -500 | 11,540 | 208 - -### Gotchas - -#### Permissions of the `.graphql-generated/` and `public/_graphql/` folders {#gotchas-permissions} - -The process that is generating these folders must have write permissions to create the folder and to update existing files. If different users are used to generate the folders, then you must make sure that each user retains write access on them. - -For example, if you manually run a `dev/build` under a foobar user, the folders will be owned by foobar. If your web server is running under the www-data user and you try to call `dev/graphql/build` in your browser, you might get an error if www-data doesn’t have write access. - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/01_getting_started/index.md b/en/02_Developer_Guides/19_GraphQL/01_getting_started/index.md deleted file mode 100644 index ef0394b25..000000000 --- a/en/02_Developer_Guides/19_GraphQL/01_getting_started/index.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Getting started -summary: Open up your first GraphQL server and build your schema -icon: rocket ---- - -# Getting started - -This section of the documentation will give you an overview of how to get a simple GraphQL API -up and running with some `DataObject` content. - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/01_adding_dataobjects_to_the_schema.md b/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/01_adding_dataobjects_to_the_schema.md deleted file mode 100644 index 342976883..000000000 --- a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/01_adding_dataobjects_to_the_schema.md +++ /dev/null @@ -1,485 +0,0 @@ ---- -title: Adding DataObject models to the schema -summary: An overview of how the DataObject model can influence the creation of types, queries, and mutations ---- - -# Working with `DataObject` models - -[CHILDREN asList] - -## The `DataObject` model type - -In Silverstripe CMS projects, our data tends to be contained in DataObjects almost exclusively, -and the `silverstripe/graphql` schema API is designed so that adding `DataObject` content to your -GraphQL schema definition is fast and simple. - -### Using model types - -While it is possible to add DataObjects to your schema as generic types under the `types` -section of the configuration, and their associated queries and mutations under `queries` and -`mutations`, this will lead to a lot of boilerplate code and repetition. Unless you have some -really custom needs, a much better approach is to embrace *convention over configuration* -and use the `models` section of the config. - -**Model types** are types that rely on external classes to tell them who they are and what -they can and cannot do. The model can define and resolve fields, auto-generate queries -and mutations, and more. - -Naturally, this module comes bundled with a model type for subclasses of `DataObject`. - -Let's use the `models` config to expose some content. - -```yml -# app/_graphql/models.yml -Page: - fields: '*' - operations: '*' -``` - -The class `Page` is a subclass of `DataObject`, so the bundled model -type will kick in here and provide a lot of assistance in building out this part of our API. - -Case in point, by supplying a value of `*` for `fields` , we're saying that we want *all* of the fields -on the `Page` class. This includes the first level of relationships, as defined on `has_one`, `has_many`, -or `many_many`. - -> [!WARNING] -> Fields on relationships will not inherit the `*` fields selector, and will only expose their ID by default. -> To add additional fields for those relationships you will need to add the corresponding `DataObject` model types. - -The `*` value on `operations` tells the schema to create all available queries and mutations - for the DataObject, including: - -- `read` -- `readOne` -- `create` -- `update` -- `delete` - -Now that we've changed our schema, we need to build it using the `dev/graphql/build` command: - -`vendor/bin/sake dev/graphql/build schema=default` - -Now we can access our schema on the default GraphQL endpoint, `/graphql`. - -Test it out! - -> [!NOTE] -> Note the use of the default arguments on `date`. Fields created from `DBFields` -> generate their own default sets of arguments. For more information, see -> [DBFieldArgs](query_plugins#dbfieldargs). - -**A query:** - -```graphql -query { - readPages { - nodes { - title - content - ... on BlogPage { - date(format: NICE) - comments { - nodes { - comment - author { - firstName - } - } - } - } - } - } -} -``` - -> [!NOTE] -> The `... on BlogPage` syntax is called an [inline fragment](https://graphql.org/learn/queries/#inline-fragments). -> You can learn more about this syntax in the [Inheritance](../inheritance) section. - -**A mutation:** - -```graphql -mutation { - createPage(input: { - title: "my page" - }) { - title - id - } -} -``` - -> [!TIP] -> Did you get a permissions error? Make sure you're authenticated as someone with appropriate access. - -### Configuring operations - -You may not always want to add *all* operations with the `*` wildcard. You can allow those you -want by setting them to `true` (or `false` to remove them). - -```yml -# app/_graphql/models.yml -Page: - fields: '*' - operations: - read: true - create: true - -App\Model\Product: - fields: '*' - operations: - '*': true - delete: false -``` - -Operations are also configurable, and accept a nested map of config. - -```yml -# app/_graphql/models.yml -Page: - fields: '*' - operations: - create: true - read: - name: getAllThePages -``` - -#### Customising the input types - -The input types, specifically in `create` and `update`, can be customised with a -list of fields. The list can include explicitly *disallowed* fields. - -```yml -# app/_graphql/models.yml -Page: - fields: '*' - operations: - create: - fields: - title: true - content: true - update: - fields: - '*': true - immutableField: false -``` - -### Adding more fields - -Let's add some more DataObjects, but this time, we'll only add a subset of fields and operations. - -```yml -# app/_graphql/models.yml -Page: - fields: '*' - operations: '*' - -App\Model\Product: - fields: - onSale: true - title: true - price: true - operations: - delete: true - -App\Model\ProductCategory: - fields: - title: true - featured: true -``` - -> [!WARNING] -> A couple things to note here: -> -> - By assigning a value of `true` to the field, we defer to the model to infer the type for the field. To override that, we can always add a `type` property: -> -> ```yml -> App\Model\Product: -> fields: -> onSale: -> type: Boolean -> ``` -> -> - The mapping of our field names to the `DataObject` property is case-insensitive. It is a -> convention in GraphQL APIs to use lowerCamelCase fields, so this is given by default. - -### Bulk loading models - -It's likely that in your application you have a whole collection of classes you want exposed to the API with roughly -the same fields and operations exposed on them. It can be really tedious to write a new declaration for every single -`DataObject` in your project, and as you add new ones, there's a bit of overhead in remembering to add it to the -GraphQL schema. - -Common use cases might be: - -- Add everything in `App\Model` -- Add every implementation of `BaseElement` -- Add anything with the `Versioned` extension -- Add everything that matches `src/*Model.php` - -You can create logic like this using the `bulkLoad` configuration file, which allows you to specify groups of directives -that load a bundle of classes and apply the same set of configuration to all of them. - -```yml -# app/_graphql/bulkLoad.yml -elemental: # An arbitrary key to define what these directives are doing - # Load all elemental blocks except MySecretElement - load: - inheritanceLoader: - include: - - DNADesign\Elemental\Models\BaseElement - exclude: - - App\Model\Elemental\MySecretElement - # Add all fields and read operations - apply: - fields: - '*': true - operations: - read: true - readOne: true - -app: - # Load everything in our App\Model\ namespace that has the Versioned extension - # unless the filename ends with .secret.php - load: - namespaceLoader: - include: - - App\Model\* - extensionLoader: - include: - - SilverStripe\Versioned\Versioned - filepathLoader: - exclude: - - app/src/Model/*.secret.php - apply: - fields: - '*': true - operations: - '*': true -``` - -By default, four loaders are provided to you to help gather specific classnames: - -#### By namespace - -- **Identifier**: `namespaceLoader` -- **Description**: Include or exclude classes based on their namespace -- **Example**: `include: [App\Model\*]` - -#### By inheritance - -- **Identifier**: `inheritanceLoader` -- **Description**: Include or exclude everything that matches or extends a given base class -- **Example**: `include: [DNADesign\Elemental\Models\BaseElement]` - -#### By applied extension - -- **Identifier**: `extensionLoader` -- **Description**: Include or exclude any class that has a given extension applied -- **Example**: `include: [SilverStripe\Versioned\Versioned]` - -#### By filepath - -- **Identifier**: `filepathLoader` -- **Description**: Include or exclude any classes in files matching a given glob expression, relative to the base path. Module syntax is allowed. -- **Examples**: - - `include: [ 'src/Model/*.model.php' ]` - - `include: [ 'somevendor/somemodule: src/Model/*.php' ]` - -> [!NOTE] -> `exclude` directives will always supersede `include` directives. - -Each block starts with a collection of all classes that gets filtered as each loader runs. The primary job -of a loader is to *remove* classes from the entire collection, not add them in. - -> [!NOTE] -> If you find that this paints with too big a brush, you can always override individual models explicitly in `models.yml`. -> The bulk loaders run *before* the `models.yml` config is loaded. - -#### `DataObject` subclasses are the default starting point - -Because this is Silverstripe CMS, and it's likely that you're using `DataObject` models only, the bulk loaders start with an -initial filter which is defined as follows: - -```yml -inheritanceLoader: - include: - - SilverStripe\ORM\DataObject -``` - -This ensures that at a bare minimum, you're always filtering by `DataObject` classes *only*. If, for some reason, you -have a non-`DataObject` class in `App\Model\*`, it will automatically be filtered out due to this default setting. - -This default is configured in the `defaultBulkLoad` setting in your schema config. Should you ever want to disable -that, just set it to `false`. - -```yml -# app/_graphql/config.yml -defaultBulkLoad: false -``` - -#### Creating your own bulk loader - -Bulk loaders must extend [`AbstractBulkLoader`](api:SilverStripe\GraphQL\Schema\BulkLoader\AbstractBulkLoader). They -need to declare an identifier (e.g. `namespaceLoader`) to be referenced in the config, and they must implement -[`collect()`](api:SilverStripe\GraphQL\Schema\BulkLoader\AbstractBulkLoader::collect()) which returns a new `Collection` -instance once the loader has done its work parsing through the `include` and `exclude` directives. - -Bulk loaders are automatically registered. Just creating the class is all you need to do to have it available for use -in your `bulkLoad.yml` file. - -### Customising model fields - -You don't have to rely on the model to tell you how fields should resolve. Just like -generic types, you can customise them with arguments and resolvers. - -```yml -# app/_graphql/models.yml -App\Model\Product: - fields: - title: - type: String - resolver: ['App\GraphQL\Resolver\ProductResolver', 'resolveSpecialTitle'] - 'price(currency: String = "NZD")': true -``` - -For more information on custom arguments and resolvers, see the -[adding arguments](../working_with_generic_types/adding_arguments) and -[resolver discovery](../working_with_generic_types/resolver_discovery) documentation. - -### Excluding or customising "*" declarations - -You can use `*` as a field or operation, and anything that follows it will override the -all-inclusive collection. This is almost like a spread operator in JavaScript: - -```js -const newObj = { ...oldObj, someProperty: 'custom' }; -``` - -Here's an example: - -```yml -# app/_graphql/models.yml -Page: - fields: - '*': true # Get everything - sensitiveData: false # hide this field - 'content(summaryLength: Int)': true # add an argument to this field - operations: - '*': true - read: - plugins: - paginateList: false # don't paginate the read operation -``` - -### Disallowed fields {#disallowed-fields} - -While selecting all fields via `*` is useful, there are some fields that you -don't want to accidentally expose, especially if you're a module author -and expect models within this code to be used through custom GraphQL endpoints. -For example, a module might add a secret "preview token" to each `SiteTree`. -A custom GraphQL endpoint might have used `fields: '*'` on `SiteTree` to list pages -on the public site, which now includes a sensitive field. - -The `graphql_blacklisted_fields` property on `DataObject` allows you to -disallow fields globally for all GraphQL schemas. -This block list applies for all operations (read, update, etc). - -```yml -# app/_config/graphql.yml -SilverStripe\CMS\Model\SiteTree: - graphql_blacklisted_fields: - myPreviewTokenField: true -``` - -### Model configuration - -There are several settings you can apply to your model class (typically `DataObjectModel`), -but because they can have distinct values *per schema*, the standard `_config` layer is not -an option. Model configuration has to be done within the schema config in the `modelConfig` -subsection. - -### Customising the type name - -Most `DataObject` classes are namespaced, so converting them to a type name ends up -being very verbose. As a default, the `DataObjectModel` class will use the "short name" -of your `DataObject` as its typename (see: [`ClassInfo::shortName()`](api:SilverStripe/Core/ClassInfo::shortName())). -That is, `App\Model\Product` becomes `Product`. - -Given the brevity of these type names, it's not inconceivable that you could run into naming -collisions, particularly if you use feature-based namespacing. Fortunately, there are -hooks you have available to help influence the typename. - -#### Explicit type mapping - -You can explicitly provide type name for a given class using the `typeMapping` setting in your schema config. - -```yml -# app/_graphql/config.yml -typeMapping: - App\PageType\Page: SpecialPage -``` - -It may be necessary to use `typeMapping` in projects that have a lot of similar class names in different namespaces, which will cause a collision -when the type name is derived from the class name. The most case for this -is the `Page` class, which may be both at the root namespace and in your -app namespace, e.g. `App\PageType\Page`. - -#### The type formatter - -The `type_formatter` is a callable that can be set on the `DataObjectModel` config. It takes -the `$className` as a parameter. - -Let's turn the type for `App\Model\Product` from `Product` into the more specific `AppProduct` - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - type_formatter: ['App\GraphQL\Formatter', 'formatType'] -``` - -> [!NOTE] -> In the above example, `DataObject` is the result of [`DataObjectModel::getIdentifier()`](api:SilverStripe\GraphQL\Schema\DataObject::getIdentifier()). -> Each model class must declare one of these. - -The formatting function in your `App\GraphQL\Formatter` class could look something like: - -```php -namespace App\GraphQL; - -class Formatter -{ - public static function formatType(string $className): string - { - $parts = explode('\\', $className); - if (count($parts) === 1) { - return $className; - } - $first = reset($parts); - $last = end($parts); - - return $first . $last; - } -} -``` - -#### The type prefix - -You can also add prefixes to all your `DataObject` types. This can be a scalar value or a callable, -using the same signature as `type_formatter`. - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - type_prefix: 'App' -``` - -This would automatically set the type name for your `App\Model\Product` class to `AppProduct` -without needing to declare a `type_formatter`. - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/02_query_plugins.md b/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/02_query_plugins.md deleted file mode 100644 index b07b2d8ec..000000000 --- a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/02_query_plugins.md +++ /dev/null @@ -1,562 +0,0 @@ ---- -title: DataObject query plugins -summary: Learn about some of the useful goodies that come pre-packaged with DataObject queries ---- - -# Working with `DataObject` models - -[CHILDREN asList] - -## `DataObject` query plugins - -This module has a [plugin system](../plugins) that affords extensibility to queries, mutations, -types, fields, and just about every other thread of the schema. Model types can define default -plugins to include, and for `DataObject` queries, these include: - -- `filter` -- `sort` -- `dbFieldArgs` -- `paginateList` -- `inheritance` -- `canView` (read, readOne) -- `firstResult` (readOne) - -When the `silverstripe/cms` module is installed, a plugin known as `getByLink` is also added. -Other modules, such as `silverstripe/versioned` may augment that list with even more. - -### The pagination plugin - -The pagination plugin augments your queries in two main ways: - -- Adding `limit` and `offset` arguments -- Wrapping the return type in a "connection" type with the following fields: - - `nodes: '[YourType]'` - - `edges: '[{ node: YourType }]'` - - `pageInfo: '{ hasNextPage: Boolean, hasPreviousPage: Boolean: totalCount: Int }'` - -Let's test it out: - -```graphql -query { - readPages(limit: 10, offset: 20) { - nodes { - title - } - edges { - node { - title - } - } - pageInfo { - totalCount - hasNextPage - hasPrevPage - } - } -} -``` - -> [!WARNING] -> If you're not familiar with the jargon of `edges` and `node`, don't worry too much about it -> for now. It's just a pretty well-established convention for pagination in GraphQL, mostly owing -> to its frequent use with [cursor-based pagination](https://graphql.org/learn/pagination/), which -> isn't something we do in Silverstripe CMS. You can ignore `edges.node` and just use `nodes` if -> you want to. - -#### Limiting pagination - -To change the limit for items per page for a given type, you can set the `maximumLimit` property on the `paginateList` plugin in the schema: - -```yml -# app/_graphql/models.yml -MyProject\Models\ProductCategory: - operations: - read: - plugins: - paginateList: - maximumLimit: 10 -``` - -To change the default limit globally, set the max_limit configuration on the `PaginationPlugin` itself: - -```yml -SilverStripe\GraphQL\Schema\Plugin\PaginationPlugin: - max_limit: 10 -``` - -> [!WARNING] -> If you want to *increase* the limit beyond the default value, you will also need to set a new `default_limit` configuration value on the `PaginationPlugin`. - -#### Disabling pagination - -Just set it to `false` in the configuration. - -```yml -# app/_graphql/models.yml -App\Model\ProductCategory: - operations: - read: - plugins: - paginateList: false -``` - -To disable pagination globally, use `modelConfig`: - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - operations: - read: - plugins: - paginateList: false -``` - -### The filter plugin - -The filter plugin ([`QueryFilter`](api:SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter)) adds a -special `filter` argument to the `read` and `readOne` operations. - -```graphql -query { - readPages( - filter: { title: { eq: "Blog" } } - ) { - nodes { - title - created - } - } -} -``` - -In the above example, the `eq` is known as a "comparator". There are several of these -included with the the module, including: - -- `eq` (exact match) -- `ne` (not equal) -- `contains` (fuzzy match) -- `gt` (greater than) -- `lt` (less than) -- `gte` (greater than or equal) -- `lte` (less than or equal) -- `in` (in a given list) -- `startswith` (starts with) -- `endswith` (ends with) - -Example: - -```graphql -query { - readPages ( - filter: { - title: { ne: "Home" }, - created: { gt: "2020-06-01", lte: "2020-09-01" } - } - ) { - nodes { - title - created - } - } -} -``` - -> [!WARNING] -> While it is possible to filter using multiple comparators, segmenting them into -> disjunctive groups (e.g. "OR" and "AND" clauses) is not yet supported. - -Nested fields are supported by default: - -```graphql -query { - readProductCategories( - filter: { - products: { - reviews: { - rating: { gt: 3 }, - comment: { contains: "awesome" }, - author: { ne: "Me" } - } - } - } - ) { - nodes { - title - } - } -} -``` - -Filters are only querying against the database by default - it is not possible to filter by -fields with custom resolvers. - -#### Customising the filter fields - -By default, all fields on the DataObject, including relationships, are included. To customise -this, just add a `fields` config to the plugin definition: - -```yml -# app/_graphql/models.yml -App\Model\ProductCategory: - fields: - title: true - featured: true - operations: - read: - plugins: - filter: - fields: - title: true -``` - -> [!NOTE] -> You can also add all fields with `'*': true`, just like with standard model definitions. - -##### Adding non-native filter fields - -Sometimes you may want to add a filter field that stems from a custom getter, or a complex computation that -isn't easily addressed by simple field comparisons. For cases like this, you can add the custom field as long -as you provide instructions on how to resolve it. - -```yml -# app/_graphql/models.yml -App\Model\Product: - fields: - title: true - price: true - operations: - read: - plugins: - filter: - fields: - title: true - hasReviews: true - resolve: - hasReviews: - type: Boolean - resolver: ['App\GraphQL\Resolver\ProductResolver', 'resolveHasReviewsFilter'] -``` - -We've added the custom field `hasReviews` as a custom field in the `fields` section of the plugin config. A custom field -like this that does not exist on the `Product` dataobject will cause the plugin to throw unless you've provided -a `resolve` directive for it. - -In the `resolve` section, we need to provide two vital pieces of information: - -- What data type will the filter value be? (boolean in this case) -- Where is the code that will apply this filter? (A static function in our `ProductResolver` class) - -The code to resolve the filter will get two relevant pieces of information in its `$context` parameter: - -- `filterComparator`: e.g. "eq", "ne", "gt", etc. -- `filterValue`: What value we're comparing (true or false, in this case, since it's a boolean) - -Here's how we can resolve this custom filter: - -```php -// app/src/GraphQL/Resolver/Resolver.php -namespace App\GraphQL\Resolver; - -use Exception; - -class ProductResolver -{ - public static function resolveHasReviewsFilter(Filterable $list, array $args, array $context) - { - $onlyWithReviews = $context['filterValue']; - $comparator = $context['filterComparator']; - - if (!in_array($comparator, ['eq', 'ne'])) { - throw new Exception('Invalid comparator for hasReviews: ' . $comparator); - } - if ($comparator === 'ne') { - $onlyWithReviews = !$onlyWithReviews; - } - - return $onlyWithReviews - ? $list->filter('Reviews.Count():GreaterThan', 0) - : $list->filter('Reviews.Count()', 0); - } -} -``` - -> [!NOTE] -> Custom filter fields are also a good opportunity to implement something like `filterByCallback` on your list for -> particularly complex computations that cannot be done at the database level. - -#### Disabling the filter plugin - -Just set it to `false` in the configuration. - -```yml -# app/_graphql/models.yml -App\Model\ProductCategory: - operations: - read: - plugins: - filter: false -``` - -To disable filtering globally, use `modelConfig`: - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - operations: - read: - plugins: - filter: false -``` - -### The sort plugins - -The sort plugin ([`QuerySort`](api:SilverStripe\GraphQL\Schema\DataObject\Plugin\QuerySort)) adds a -special `sort` argument to the `read` and `readOne` operations. - -```graphql -query { - readPages ( - sort: { created: DESC } - ) { - nodes { - title - created - } - } -} -``` - -Nested fields are supported by default, but only for linear relationships (e.g. `has_one`): - -```graphql -query { - readProducts( - sort: { - primaryCategory: { - lastEdited: DESC - } - } - ) { - nodes { - title - } - } -} -``` - -In addition, you can use the field sorting plugin ([`SortPlugin`](api:SilverStripe\GraphQL\Schema\Plugin\SortPlugin)) to sort fields that represent `has_many` and `many_many` relationships. To do this, simply add the desired fields to the query, as well as the `sort` argument to these fields. It is also necessary to update the scheme by adding a `sorter` plugin to those fields that need to be sorted. - -Example how to use SortPlugin. - -```graphql -query { - readPages ( - sort: { created: DESC } - ) { - nodes { - title - created - hasManyFilesField (sort: { parentFolderID: DESC, title: ASC }) { - name - } - } - } -} -``` - -```yml -# app/_graphql/models.yml -Page: - operations: - read: - plugins: - sort: - before: paginateList - fields: - created: true - fields: - title: true - created: true - hasManyFilesField: - fields: - name: true - plugins: - sorter: - fields: - title: true - parentFolderID: true -``` - -#### Customising the sort fields - -By default, all fields on the DataObject, including `has_one` relationships, are included. -To customise this, just add a `fields` config to the plugin definition: - -```yml -# app/_graphql/models.yml -App\Model\ProductCategory: - fields: - title: true - featured: true - operations: - read: - plugins: - sort: - fields: - title: true -``` - -#### Disabling the sort plugin - -Just set it to `false` in the configuration. - -```yml -# app/_graphql/models.yml -App\Model\ProductCategory: - operations: - read: - plugins: - sort: false -``` - -To disable sort globally, use `modelConfig`: - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - operations: - read: - plugins: - sort: false -``` - -### The `DBFieldArgs` plugin {#dbfieldargs} - -When fields are introspected from a model and reference a `DBField` instance, -they get populated with a default set of arguments that map to methods on that -`DBField` class, for instance `$field->Nice()` or `$field->LimitSentences(4)`. - -Let's have a look at this query: - -```graphql -query { - readPages { - nodes { - content(format: LIMIT_SENTENCES, limit: 4) - created(format: NICE) - - ... on BlogPage { - introText(format: FIRST_PARAGRAPH) - publishDate(format: CUSTOM, customFormat: "dd/MM/yyyy") - } - } - } -} -``` - -The primary field types that are affected by this include: - -- `DBText` (including `DBHTMLText`) -- `DBDate` (including `DBDatetime`) -- `DBTime` -- `DBDecimal` -- `DBFloat` - -#### All available arguments - -##### `DBText` - -- `format: CONTEXT_SUMMARY` (optional "limit" arg) -- `format: FIRST_PARAGRAPH` -- `format: LIMIT_SENTENCES` (optional "limit" arg) -- `format: SUMMARY` (optional "limit" arg) -- `parseShortcodes: Boolean` (DBHTMLText only) - -##### `DBDate` - -- `format: TIMESTAMP` -- `format: NICE` -- `format: DAY_OF_WEEK` -- `format: MONTH` -- `format: YEAR` -- `format: SHORT_MONTH` -- `format: DAY_OF_MONTH` -- `format: SHORT` -- `format: LONG` -- `format: FULL` -- `format: CUSTOM` (requires `customFormat: String` arg) - -##### `DBTime` - -- `format: TIMESTAMP` -- `format: NICE` -- `format: SHORT` -- `format: CUSTOM` (requires `customFormat: String` arg) - -##### `DBDecimal` - -- `format: INT` - -##### `DBFloat` - -- `format: NICE` -- `format: ROUND` -- `format: NICE_ROUND` - -#### Enum naming strategy and deduplication - -By default, auto-generated Enum types will use as generic a name as possible using the convention `Enum` (e.g. -`OrderStatusEnum`). On occasion, this may collide with other types (e.g. `OptionsEnum` is quite generic and likely to be used already). -In this case, the second enum generated will use `Enum` (e.g. `MyTypeOptionsEnum`). - -If an enum already exists with the same fields and name, it will be reused. For instance, if `OptionsEnum` -is found and has exactly the same defined values (in the same order) as the Enum being generated, -it will be reused rather than proceeding to the deduplication strategy. - -#### Custom enum names - -You can specify custom enum names in the plugin config: - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - plugins: - dbFieldTypes: - enumTypeMapping: - MyType: - myEnumField: SomeCustomTypeName -``` - -You can also specify enums to be ignored. (`ClassName` does this on all DataObjects to prevent inheritance -issues) - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - plugins: - dbFieldTypes: - ignore: - MyType: - myEnumField: true -``` - -### The getByLink plugin - -When the `silverstripe/cms` module is installed (it is in most cases), a plugin called `getByLink` -will ensure that queries that return a single `DataObject` model (e.g. `readOne`) get a new query argument -called `link` (configurable on the `field_name` property of `LinkablePlugin`). - -```graphql -readOneSiteTree(link: "/about-us" ) { - title -} -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/03_permissions.md b/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/03_permissions.md deleted file mode 100644 index 2f9e2ba81..000000000 --- a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/03_permissions.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: DataObject operation permissions -summary: A look at how permissions work for DataObject queries and mutations ---- - -# Working with `DataObject` models - -[CHILDREN asList] - -## `DataObject` operation permissions - -Any of the operations that come pre-configured for DataObjects are secured by the appropriate permissions -by default. -Please see [Model-Level Permissions](/developer_guides/model/permissions/#model-level-permissions) for more information. - -### Mutation permssions - -> [!NOTE] -> When mutations fail due to permission checks, they throw a [`PermissionsException`](api:SilverStripe\GraphQL\Schema\Exception\PermissionsException). - -For `create`, if a singleton instance of the record being created doesn't pass a `canCreate($member)` check, -the mutation will throw. - -For `update`, if the record matching the given ID doesn't pass a `canEdit($member)` check, the mutation will -throw. - -For `delete`, if any of the given IDs don't pass a `canDelete($member)` check, the mutation will throw. - -### Query permissions - -Query permissions are a bit more complicated, because they can either be in list form, (paginated or not), -or a single item. Rather than throw, these permission checks work as filters. - -> [!WARNING] -> It is critical that you have a `canView()` method defined on your DataObjects. Without this, only admins are -> assumed to have permission to view a record. - -For `read` and `readOne` a plugin called `canView` will filter the result set by the `canView($member)` check. - -> [!WARNING] -> When paginated items fail a `canView()` check, the `pageInfo` field is not affected. -> Limits and pages are determined through database queries. It would be too inefficient to perform in-memory checks on large data sets. -> This can result in pages showing a smaller number of items than what the page should contain, but keeps the pagination calls consistent -> for `limit` and `offset` parameters. - -### Disabling query permissions - -Though not recommended, you can disable query permissions by setting their plugins to `false`. - -```yml -# app/_graphql/models.yml -Page: - operations: - read: - plugins: - canView: false - readOne: - plugins: - canView: false -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/04_inheritance.md b/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/04_inheritance.md deleted file mode 100644 index 07ac51ba2..000000000 --- a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/04_inheritance.md +++ /dev/null @@ -1,483 +0,0 @@ ---- -title: DataObject inheritance -summary: Learn how inheritance is handled in DataObject model types ---- - -# Working with `DataObject` models - -[CHILDREN asList] - -## `DataObject` inheritance - -The inheritance pattern used in the ORM is a tricky thing to navigate in a GraphQL API, mostly owing -to the fact that there is no concept of inheritance in GraphQL types. The main tools we have at our -disposal are [interfaces](https://graphql.org/learn/schema/#interfaces) and [unions](https://graphql.org/learn/schema/#union-types) -to deal with this type of architecture, and we leverage both of them when working with DataObjects. - -### Key concept: querying types that have descendants - -When you query a type that has descendant classes, you are by definition getting a polymorphic return. There -is no guarantee that each result will be of one specific type. Take this example: - -```graphql -query { - readPages { - nodes { - title - content - } - } -} -``` - -This is fine when the two fields are common to across the entire inheritance chain, but what happens -when we need the `date` field on `BlogPage`? - -```graphql -query { - readPages { - nodes { - title - content - date # fails! - } - } -} -``` - -To solve this problem, the GraphQL module will automatically change these types of queries to return interfaces. - -```graphql -query { - readPages { - nodes { # <--- [PageInterface] - title - content - } - } -} -``` - -Now, in order to query fields that are specific to `BlogPage`, we need to use an -[inline fragment](https://graphql.org/learn/queries/#inline-fragments) to select them. - -In the below example, we are querying `title` and `content` on all page types, but we only query `heroImage` -on `HomePage` objects, and we query `date` and `author` only for `BlogPage` objects. - -```graphql -query { - readPages { - nodes { - title # Common field - content # Common field - ... on HomePage { - heroImage { - url - } - } - ... on BlogPage { - date - author { - firstName - } - } - } - } -} -``` - -So the fields that are common to every possible type in the result set can be directly selected (with no `...on` -syntax), because they're part of the common interface. They're guaranteed to exist on every type. But for fields -that only appear on some types, we need to be explicit. - -Now let's take this a step further. What if there's another class in between? Imagine this ancestry: - -```text -Page - -> EventPage extends Page - -> ConferencePage extends EventPage - -> WebinarPage extends EventPage -``` - -We can use the intermediary interface `EventPageInterface` to consolidate fields that are unique to -`ConferencePage` and `WebinarPage`. - -```graphql -query { - readPages { - nodes { - title # Common to all types - content # Common to all types - ... on EventPageInterface { - # Common fields for WebinarPage, ConferencePage, EventPage - numberOfTickets - featuredSpeaker { - firstName - email - } - } - ... on WebinarPage { - zoomLink - } - ... on ConferencePage { - venueSize - } - ... on BlogPage { - date - author { - firstName - } - } - } - } -} -``` - -You can think of interfaces in this context as abstractions of *parent classes* - and the best part is -they're generated automatically. We don't need to manually define or implement the interfaces. - -> [!NOTE] -> A good way to determine whether you need an inline fragment is to ask -> "can this field appear on any other types in the query?" If the answer is yes, you want to use an interface, -> which is usually the parent class with the "Interface" suffix. - -### Inheritance: a deep dive - -There are several ways inheritance is handled at build time: - -- Implicit field / type exposure -- Interface generation -- Assignment of generated interfaces to types -- Assignment of generated interfaces to queries - -We'll look at each of these in detail. - -#### Inherited fields / implicit exposures - -Here are the rules for how inheritance affects types and fields: - -- Exposing a type implicitly exposes all of its ancestors. -- Ancestors receive any fields exposed by their descendants, if applicable. -- Exposing a type applies all of its fields to descendants only if they are explicitly exposed also. - -All of this is serviced by: [`InheritanceBuilder`](api:SilverStripe\GraphQL\Schema\DataObject\InheritanceBuilder) - -##### Example {#fields-example} - -```yml -App\PageType\BlogPage: - fields: - title: true - content: true - date: true - -App\PageType\GalleryPage: - fields: - images: true - urlSegment: true -``` - -This results in those two types being exposed with the fields as shown, but also results in a `Page` type: - -```graphql -type Page { - id: ID! # always exposed - title: String - content: String - urlSegment: String -} -``` - -#### Interface generation - -Any type that's part of an inheritance chain will generate interfaces. Each applicable ancestral interface is added -to the type. Like the type inheritance pattern shown above, interfaces duplicate fields from their ancestors as well. - -Additionally, a **base interface** is provided for all types containing common fields across the entire `DataObject` -schema. - -All of this is serviced by: [`InterfaceBuilder`](api:SilverStripe\GraphQL\Schema\DataObject\InterfaceBuilder) - -##### Example {#interface-example} - -```text -Page - -> BlogPage extends Page - -> EventsPage extends Page - -> ConferencePage extends EventsPage - -> WebinarPage extends EventsPage -``` - -This will create the following interfaces (assuming the fields below are exposed): - -```graphql -interface PageInterface { - id: ID! - title: String - content: String -} - -interface BlogPageInterface { - id: ID! - title: String - content: String - date: String -} - -interface EventsPageInterface { - id: ID! - title: String - content: String - numberOfTickets: Int -} - -interface ConferencePageInterface { - id: ID! - title: String - content: String - numberOfTickets: Int - venueSize: Int - venurAddress: String -} - -interface WebinarPageInterface { - id: ID! - title: String - content: String - numberOfTickets: Int - zoomLink: String -} -``` - -#### Interface assignment to types - -The generated interfaces then get applied to the appropriate types, like so: - -```graphql -type Page implements PageInterface {} -type BlogPage implements BlogPageInterface & PageInterface {} -type EventsPage implements EventsPageInterface & PageInterface {} -type ConferencePage implements ConferencePageInterface & EventsPageInterface & PageInterface {} -type WebinarPage implements WebinarPageInterface & EventsPageInterface & PageInterface {} -``` - -Lastly, for good measure, we create a `DataObjectInterface` that applies to everything. - -```graphql -interface DataObjectInterface { - id: ID! - # Any other fields you've explicitly exposed in config.modelConfig.DataObject.base_fields -} -``` - -```graphql -type Page implements PageInterface & DataObjectInterface {} -``` - -#### Interface assignment to queries - -Queries, both at the root and nested as fields on types, will have their types -updated if they refer to a type that has had any generated interfaces added to it. - -```graphql -type Query { - readPages: [Page] -} - -type BlogPage { - download: File -} -``` - -Becomes: - -```graphql -type Query { - readPages: [PageInterface] -} - -type BlogPage { - download: FileInterface -} -``` - -All of this is serviced by: [`InterfaceBuilder`](api:SilverStripe\GraphQL\Schema\DataObject\InterfaceBuilder) - -#### Elemental - -This section refers to types added via `dnadesign/silverstripe-elemental`. - -Almost by definition, content blocks are always abstractions. You're never going to query for a `BaseElement` type -specifically. You're always asking for an assortment of its descendants, which adds a lot of polymorphism to -the query. - -```graphql -query { - readElementalPages { - nodes { - elementalArea { - elements { - nodes { - title - id - ... on ContentBlock { - html - } - ... on CTABlock { - link - linkText - } - } - } - } - } - } -} -``` - -> [!NOTE] -> The above example shows a query for elements on all elemental pages - but for most situations you will -> probably only want to query the elements on one page at a time. - -### Optional: use unions instead of interfaces - -You can opt out of using interfaces as your return types for queries and instead use a union of all the concrete -types. This comes at a cost of potentially breaking your API unexpectedly (described below), so it is not enabled by -default. There is no substantive advantage to using unions over interfaces for your query return types. It would -typically only be done for conceptual purposes. - -To use unions, turn on the `useUnionQueries` setting. - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - plugins: - inheritance: - useUnionQueries: true -``` - -This means that models that have descendants will create unions that include themselves and all of their descendants. -For queries that return those models, a union is put in its place. - -Serviced by: [`InheritanceUnionBuilder`](api:SilverStripe\GraphQL\Schema\DataObject\InheritanceUnionBuilder) - -#### Example {#unions-example} - -```text -type Page implements PageInterface {} -type BlogPage implements BlogPageInterface & PageInterface {} -type EventsPage implements EventsPageInterface & PageInterface {} -type ConferencePage implements ConferencePageInterface & EventsPageInterface & PageInterface {} -type WebinarPage implements WebinarPageInterface & EventsPageInterface & PageInterface {} -``` - -Creates the following unions: - -```text -union PageInheritanceUnion = Page | BlogPage | EventsPage | ConferencePage | WebinarPage -union EventsPageInheritanceUnion = EventsPage | ConferencePage | WebinarPage -``` - -"Leaf" models like `BlogPage`, `ConferencePage`, and `WebinarPage` that have no exposed descendants will not create -unions, as they are functionally useless. - -This means that queries for `readPages` and `readEventsPages` will now return unions. - -```graphql -query { - readPages { - nodes { - ... on PageInterface { - id # in theory, this common field could be done on DataObjectInterface, but that gets a bit verbose - title - content - } - ... on EventsPageInterface { - numberOfTickets - } - ... on BlogPage { - date - } - ... on WebinarPage { - zoomLink - } - } - } -} -``` - -#### Lookout for the footgun - -Because unions are force substituted for your queries when a model has exposed descendants, it is possible that adding -a subclass to a model will break your queries without much warning to you. - -For instance: - -```php -namespace App\Model; - -class Product extends DataObject -{ - private static $db = ['Price' => 'Int']; -} -``` - -We might query this with: - -```graphql -query { - readProducts { - nodes { - price - } - } -} -``` - -But if we create a subclass for product and expose it to GraphQL: - -```php -namespace App\Model; - -class DigitalProduct extends Product -{ - private static $db = ['DownloadURL' => 'Varchar']; -} -``` - -Now our query breaks: - -```graphql -query { - readProducts { - nodes { - price # Error: Field "price" not found on ProductInheritanceUnion - } - } -} -``` - -We need to revise it: - -```graphql -query { - readProducts { - nodes { - ... on ProductInterface { - price - } - ... on DigitalProduct { - downloadUrl - } - } - } -} -``` - -If we use interfaces, this won't break because the `price` field will be on `ProductInterface` -which makes it directly queryable (without requiring the inline fragment). - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/05_versioning.md b/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/05_versioning.md deleted file mode 100644 index e213976c7..000000000 --- a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/05_versioning.md +++ /dev/null @@ -1,190 +0,0 @@ ---- -title: Versioned content -summary: A guide on how DataObject models with the Versioned extension behave in GraphQL schemas ---- - -# Working with `DataObject` models - -[CHILDREN asList] - -## Versioned content - -For the most part, if your `DataObject` has the `Versioned` extension applied, there is nothing you need to do -explicitly - but be aware that it will affect the operations and fields of your type. -You can also [disable](#disable) versioning for your schema if you don't need it. - -See [model versioning](/developer_guides/model/versioning) for general information about versioning your DataObjects. - -### Versioned plugins - -There are several plugins provided by the `silverstripe/versioned` module that affect how versioned DataObjects -appear in the schema. These include: - -- The `versioning` plugin, applied to the `DataObject` type -- The `readVersion` plugin, applied to the queries for the DataObject -- The `unpublishOnDelete` plugin, applied to the delete mutation - -Let's walk through each one. - -#### The `versioning` plugin - -Defined in the [`VersionedDataObject`](api:SilverStripe\Versioned\GraphQL\Plugins\VersionedDataObject) class, this plugin adds -several fields to the `DataObject` type, including: - -##### The `version` field - -The `version` field on your `DataObject` will include the following fields: - -- `author`: Member (Object -- the author of the version) -- `publisher`: Member (Object -- the publisher of the version) -- `published`: Boolean (True if the version is published) -- `liveVersion`: Boolean (True if the version is the one that is currently live) -- `latestDraftVersion`: Boolean (True if the version is the latest draft version) - -> [!NOTE] -> Note that `author` and `publisher` are in relation to the given *version* of the object - these are -> not necessarily the same as the author and publisher of the *original* record (i.e. the author may not -> be the person who created the object, they're the person who saved a specific version of it). - -Let's look at it in context: - -```graphql -query readPages { - nodes { - title - version { - author { - firstname - } - published - } - } -} -``` - -##### The `versions` field - -The `versions` field on your `DataObject` will return a list of the `version` objects described above. -The list is sortable by version number, using the `sort` parameter. - -```graphql -query readPages { - nodes { - title - versions(sort: { version: DESC }) { - author { - firstname - } - published - } - } -} -``` - -#### The `readVersion` plugin - -This plugin updates the `read` operation to include a `versioning` argument that contains the following -fields: - -- `mode`: VersionedQueryMode (An enum of [`ARCHIVE`, `LATEST`, `DRAFT`, `LIVE`, `STATUS`, `VERSION`]) -- `archiveDate`: String (The archive date to read from) -- `status`: VersionedStatus (An enum of [`PUBLISHED`, `DRAFT`, `ARCHIVED`, `MODIFIED`]) -- `version`: Int (The exact version to read) - -The query will automatically apply the settings from the `versioning` input type to the query and affect -the resulting `DataList`. - -#### The `unpublishOnDelete` plugin - -This is mostly for internal use. It's an escape hatch for tidying up after a delete. - -### Versioned operations - -DataObjects with the `Versioned` extension applied will also receive four extra operations -by default. They include: - -- `publish` -- `unpublish` -- `copyToStage` -- `rollback` - -All of these identifiers can be used in the `operations` config for your versioned -`DataObject`. They will all be included if you use `operations: '*'`. - -```yml -# app/_graphql/models.yml -App\Model\MyObject: - fields: '*' - operations: - publish: true - unpublish: true - rollback: true - copyToStage: true -``` - -#### Using the operations - -Let's look at a few examples: - -##### Publishing - -```graphql -mutation publishSiteTree(id: 123) { - id - title -} -``` - -##### Unpublishing - -```graphql -mutation unpublishSiteTree(id: 123) { - id - title -} -``` - -##### Rolling back - -```graphql -mutation rollbackSiteTree(id: 123, toVersion: 5) { - id - title -} -``` - -##### Copying to stage - -```graphql -mutation copySiteTreeToStage(id: 123, fromStage: DRAFT, toStage: LIVE) { - id - title -} -``` - -### Disabling versioning on your schema {#disable} - -Versioning is great for Content APIs (e.g. previews), but often not necessary for public APIs focusing on published data. -You can disable versioning for your schema in the `modelConfig` section: - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - plugins: - versioning: false - operations: - read: - plugins: - readVersion: false - readOne: - plugins: - readVersion: false - delete: - plugins: - unpublishOnDelete: false -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/06_property_mapping.md b/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/06_property_mapping.md deleted file mode 100644 index 1f190668f..000000000 --- a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/06_property_mapping.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -title: Property mapping and dot syntax -summary: Learn how to customise field names, use dot syntax, and use aggregate functions ---- - -# Working with `DataObject` models - -[CHILDREN asList] - -## Property mapping and dot syntax - -For the most part, field names are inferred through the `DataObject` model, but its API affords developers full -control over naming. - -In this example, we are taking a property `content` (which will be defined as `Content` in PHP) and defining it -as `pageContent` for GraphQL queries and mutations. - -```yml -# app/_graphql/models.yml -Page: - fields: - pageContent: - type: String - property: Content -``` - -> [!WARNING] -> When using explicit property mapping, you must also define an explicit type, as it can -> no longer be inferred. - -### Dot-separated accessors - -Property mapping is particularly useful when using **dot syntax** to access fields. - -```yml -# app/_graphql/models.yml -App\PageType\Blog: - fields: - title: true - authorName: - type: String - property: 'Author.FirstName' -``` - -Fields on `has_many` or `many_many` relationships will automatically convert to a `column` array: - -```yml -# app/_graphql/models.yml -App\PageType\Blog: - fields: - title: true - categoryTitles: - type: '[String]' - property: 'Categories.Title' - authorsFavourites: - type: '[String]' - property: 'Author.FavouritePosts.Title' -``` - -We can even use a small subset of **aggregates**, including `Count()`, `Max()`, `Min()` and `Avg()`. - -```yml -# app/_graphql/models.yml -App\Model\ProductCategory: - fields: - title: true - productCount: - type: Int - property: 'Products.Count()' - averageProductPrice: - type: Float - property: 'Products.Avg(Price)' -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/07_nested_definitions.md b/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/07_nested_definitions.md deleted file mode 100644 index 49e292282..000000000 --- a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/07_nested_definitions.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: Nested type definitions -summary: Define dependent types inline with a parent type ---- -# Working with `DataObject` models - -[CHILDREN asList] - -## Nested type definitions - -For readability and ergonomics, you can take advantage of nested type definitions. Let's imagine -we have a `Blog` and we want to expose `Author` and `Categories`, but while we're at it, we want -to specify what fields they should have. - -```yml -# app/_graphql/models.yml -App\PageType\Blog: - fields: - title: true - author: - fields: - firstName: true - surname: true - email: true - categories: - fields: '*' -``` - -Alternatively, we could flatten that out: - -```yml -# app/_graphql/models.yml -App\PageType\Blog: - fields: - title: true - author: true - categories: true -SilverStripe\Security\Member: - fields: - firstName: true - surname: true - email: true -App\Model\BlogCategory: - fields: '*' -``` - -> [!NOTE] -> You cannot define operations on nested types. They only accept fields. - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/index.md b/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/index.md deleted file mode 100644 index df46b1e3c..000000000 --- a/en/02_Developer_Guides/19_GraphQL/02_working_with_dataobjects/index.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Working with DataObject models -summary: Add DataObject models to your schema, expose their fields, add read/write operations, and more -icon: database ---- - -# Working with `DataObject` models - -In this section of the documentation, we'll cover adding DataObjects to the schema, exposing their fields, -and adding read/write operations. We'll also look at some of the plugins that are available to DataObjects -like [sorting, filtering, and pagination](query_plugins), as well as some more advanced concepts like -[permissions](permissions), [inheritance](inheritance) and [property mapping](property_mapping). - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/01_creating_a_generic_type.md b/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/01_creating_a_generic_type.md deleted file mode 100644 index c110e6070..000000000 --- a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/01_creating_a_generic_type.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: Creating a generic type -summary: Creating a type that doesn't map to a DataObject ---- - -# Working with generic types - -[CHILDREN asList] - -## Creating a generic type - -Let's create a simple type that will work with the inbuilt features of Silverstripe CMS. -We'll define some languages based on the `i18n` API. - -```yml -# app/_graphql/types.yml -Country: - fields: - code: String! - name: String! -``` - -We've defined a type called `Country` that has two fields: `code` and `name`. An example record -could be something like: - -```php -[ - 'code' => 'bt', - 'name' => 'Bhutan', -] -``` - -That's all we have to do for now! We'll need to tell GraphQL how to get this data, but first -we need to [building a custom query](building_a_custom_query) to see how we can use it. - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/02_building_a_custom_query.md b/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/02_building_a_custom_query.md deleted file mode 100644 index 2cda24610..000000000 --- a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/02_building_a_custom_query.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -title: Building a custom query -summary: Add a custom query for any type of data ---- -# Working with generic types - -[CHILDREN asList] - -## Building a custom query - -We've defined the shape of our data, now we need a way to access it. For this, -we'll need a query. Let's add one to the `queries` section of our config. - -```yml -# app/_graphql/schema.yml -queries: - readCountries: '[Country]' -``` - -### Resolving fields - -Now we have a query that will return all the countries. In order to make this work, we'll -need a **resolver** to tell the query where to get the data from. For this, we're going to -have to break out of the configuration layer and write some PHP code. - -```php -// app/src/GraphQL/Resolver/MyResolver.php -namespace App\GraphQL\Resolver; - -use SilverStripe\Core\Injector\Injector; -use SilverStripe\i18n\Data\Locales; - -class MyResolver -{ - public static function resolveCountries(): array - { - $results = []; - $countries = Injector::inst()->get(Locales::class)->getCountries(); - foreach ($countries as $code => $name) { - $results[] = [ - 'code' => $code, - 'name' => $name, - ]; - } - - return $results; - } -} -``` - -Resolvers are pretty loosely defined, and don't have to adhere to any specific contract -other than that they **must be static methods**. You'll see why when we add it to the configuration: - -```yml -# app/_graphql/schema.yml -queries: - readCountries: - type: '[Country]' - resolver: [ 'App\GraphQL\Resolver\MyResolver', 'resolveCountries' ] -``` - -> [!WARNING] -> Note the difference in syntax here between the `type` and the `resolver` - the type declaration -> *must* have quotes around it, because we are saying "this is a list of `Country` objects". The value -> of this must be a YAML *string*. But the resolver must *not* be surrounded in quotes. It is explicitly -> a YAML array, so that PHP recognises it as a `callable`. - -Now, we just have to build the schema: - -`vendor/bin/sake dev/graphql/build schema=default` - -### Testing the query - -Let's test this out in our GraphQL IDE. If you have the [`silverstripe/graphql-devtools`](https://github.com/silverstripe/silverstripe-graphql-devtools) -module installed, just go to `/dev/graphql/ide` in your browser. - -As you start typing, it should autocomplete for you. - -Here's our query: - -```graphql -query { - readCountries { - name - code - } -} -``` - -And the expected response: - -```json -{ - "data": { - "readCountries": [ - { - "name": "Afghanistan", - "code": "af" - }, - { - "name": "Åland Islands", - "code": "ax" - }, - "... etc" - ] - } -} -``` - -> [!WARNING] -> Keep in mind that [plugins](../working_with_DataObjects/query_plugins) -> don't apply in this context - at least without updating the resolver -> to account for them. Most importantly this means you need to -> implement your own `canView()` checks. It also means you need -> to add your own filter functionality, such as [pagination](adding_pagination). - -## Resolver method arguments - -A resolver is executed in a particular query context, which is passed into the method as arguments. - -- `mixed $value`: An optional value of the parent in your data graph. - Defaults to `null` on the root level, but can be useful to retrieve the object - when writing field-specific resolvers (see [Resolver Discovery](resolver_discovery)). -- `array $args`: An array of optional arguments for this field (which is different from the [Query Variables](https://graphql.org/learn/queries/#variables)) -- `array $context`: An arbitrary array which holds information shared between resolvers. - Use implementors of [`ContextProvider`](api:SilverStripe\GraphQL\Schema\Interfaces\ContextProvider) to get and set - data, rather than relying on the array keys directly. -- [`?ResolveInfo`](api:GraphQL\Type\Definition\ResolveInfo)`$info`: Data structure containing useful information for the resolving process (e.g. the field name). - See [Fetching Data](https://webonyx.github.io/graphql-php/data-fetching/) in the underlying PHP library for details. - -## Using context providers - -The `$context` array can be useful to get access to the HTTP request, -retrieve the current member, or find out details about the schema. -You can use it through implementors of the `ContextProvider` interface. -In the example below, we'll demonstrate how you could limit viewing the country code to -users with ADMIN permissions. - -```php -// app/src/GraphQL/Resolver/MyResolver.php -namespace App\GraphQL\Resolver; - -use GraphQL\Type\Definition\ResolveInfo; -use SilverStripe\Core\Injector\Injector; -use SilverStripe\GraphQL\QueryHandler\UserContextProvider; -use SilverStripe\Security\Permission; -use SilverStripe\i18n\Data\Locales; - -class MyResolver -{ - public static function resolveCountries( - mixed $value = null, - array $args = [], - array $context = [], - ?ResolveInfo $info = null - ): array { - $member = UserContextProvider::get($context); - $canViewCode = ($member && Permission::checkMember($member, 'ADMIN')); - $results = []; - $countries = Injector::inst()->get(Locales::class)->getCountries(); - foreach ($countries as $code => $name) { - $results[] = [ - 'code' => $canViewCode ? $code : '', - 'name' => $name, - ]; - } - - return $results; - } -} -``` - -## Resolver discovery - -This is great, but as we write more and more queries for types with more and more fields, -it's going to get awfully laborious mapping all these resolvers. Let's clean this up a bit by -adding a bit of convention over configuration, and save ourselves a lot of time to boot. We can do -that using the [resolver discovery pattern](resolver_discovery). - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/03_resolver_discovery.md b/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/03_resolver_discovery.md deleted file mode 100644 index c938a1dd3..000000000 --- a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/03_resolver_discovery.md +++ /dev/null @@ -1,191 +0,0 @@ ---- -title: The resolver discovery pattern -summary: How you can opt out of mapping fields to resolvers by adhering to naming conventions ---- - -# Working with generic types - -[CHILDREN asList] - -## The resolver discovery pattern - -When you define a query, mutation, or any other field on a type, you can opt out of providing -an explicit resolver and allow the system to discover one for you based on naming convention. - -Let's start by registering a resolver class where we can define a bunch of these methods. - -You can register as many classes as makes sense - and each resolver class can have multiple -resolver methods. - -```yml -# app/_graphql/config.yml -resolvers: - - App\GraphQL\Resolver\MyResolver -``` - -What we're registering here is a generic class that should contain one or more static functions that resolve one -or many fields. How those functions will be discovered relies on the *resolver strategy*. - -### Resolver strategy - -Each schema config accepts a `resolverStrategy` property. This should map to a callable that will return -a method name given a class name, type name, and [`Field`](api:SilverStripe\GraphQL\Schema\Field\Field) instance. - -```php -namespace App\GraphQL\Resolver; - -use SilverStripe\GraphQL\Schema\Field\Field; - -class Strategy -{ - public static function getResolverMethod(string $className, ?string $typeName = null, ?Field $field = null): ?string - { - // strategy logic here - } -} -``` - -#### The default resolver strategy - -By default, all schemas use [`DefaultResolverStrategy::getResolverMethod()`](api:SilverStripe\GraphQL\Schema\Resolver\DefaultResolverStrategy::getResolverMethod()) -to discover resolver functions. The logic works like this: - -- Does `resolve` exist? - - Yes? Return that method name - - No? Continue -- Does `resolve` exist? - - Yes? Return that method name - - No? Continue -- Does `resolve` exist? - - Yes? Return that method name - - No? Continue -- Does `resolve` exist? - - Yes? Return that method name - - No? Return null. This resolver cannot be discovered - -Let's look at our query again: - -```graphql -query { - readCountries { - name - } -} -``` - -Imagine we have two classes registered under `resolvers` - `ClassA` and `ClassB` - -```yml -# app/_graphql/config.yml -resolvers: - - App\GraphQL\Resolver\ClassA - - App\GraphQL\Resolver\ClassB -``` - -The `DefaultResolverStrategy` will check for methods in this order: - -- `ClassA::resolveCountryName()` -- `ClassA::resolveCountry()` -- `ClassA::resolveName()` -- `ClassA::resolve()` -- `ClassB::resolveCountryName()` -- `ClassB::resolveCountry()` -- `ClassB::resolveName()` -- `ClassB::resolve()` -- Return `null`. - -You can implement whatever strategy you like in your schema. Just register it to `resolverStrategy` in the config. - -```yml -# app/_graphql/config.yml -resolverStrategy: [ 'App\GraphQL\Resolver\Strategy', 'getResolverMethod' ] -``` - -Let's add a resolver method to our resolver provider: - -```php -// app/src/GraphQL/Resolver/MyResolver.php -namespace App\GraphQL\Resolver; - -use SilverStripe\Core\Injector\Injector; -use SilverStripe\i18n\Data\Locales; - -class MyResolver -{ - public static function resolveReadCountries() - { - $results = []; - $countries = Injector::inst()->get(Locales::class)->getCountries(); - foreach ($countries as $code => $name) { - $results[] = [ - 'code' => $code, - 'name' => $name, - ]; - } - - return $results; - } -} -``` - -Now that we're using logic to discover our resolver, we can remove our resolver method declarations from the individual -queries and instead just register the resolver class. - -```yml -# app/_graphql/config.yml -resolvers: - - App\GraphQL\Resolver\MyResolver -``` - -```yml -# app/_graphql/schema.yml - queries: - readCountries: '[Country]' -``` - -Re-run the schema build, with a flush (because we created a new PHP class), and let's go! - -`vendor/bin/sake dev/graphql/build schema=default flush=1` - -### Field resolvers - -A less magical approach to resolver discovery is defining a `fieldResolver` property on your -types. This is a generic handler for all fields on a given type and can be a nice middle -ground between the rigor of hard coding everything at a query level, and the opacity of discovery logic. - -```yml -# app/_graphql/schema.yml -types: - Country: - fields: - name: String - code: String - fieldResolver: [ 'App\GraphQL\Resolver\MyResolver', 'resolveCountryFields' ] -``` - -In this case the registered resolver method will be used to resolve any number of fields. -You'll need to do explicit checks for the field name in your resolver to make this work. - -```php -namespace App\GraphQL\Resolver; - -use GraphQL\Type\Definition\ResolveInfo; - -class MyResolver -{ - // ... - - public static function resolveCountryFields($obj, $args, $context, ResolveInfo $info) - { - $fieldName = $info->fieldName; - if ($fieldName === 'image') { - return $obj->getImage()->getURL(); - } - // ... - } -} -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/04_adding_arguments.md b/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/04_adding_arguments.md deleted file mode 100644 index 62b13734a..000000000 --- a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/04_adding_arguments.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: Adding arguments -summary: Add arguments to your fields, queries, and mutations ---- - -# Working with generic types - -[CHILDREN asList] - -## Adding arguments - -Fields can have arguments, and queries are just fields, so let's add a simple -way of influencing our query response: - -```yml -# app/_graphql/schema.yml -queries: - 'readCountries(limit: Int!)': '[Country]' -``` - -> [!TIP] -> In the above example, the `limit` argument is *required* by making it non-nullable. If you want to be able -> to get an un-filtered list, you can instead allow the argument to be nullable by removing the `!`: -> `'readCountries(limit: Int)': '[Country]'` - -We've provided the required argument `limit` to the query, which will allow us to truncate the results. -Let's update the resolver accordingly. - -```php -namespace App\GraphQL\Resolver; - -use SilverStripe\Core\Injector\Injector; -use SilverStripe\i18n\Data\Locales; - -class MyResolver -{ - public static function resolveReadCountries($obj, array $args = []) - { - $limit = $args['limit']; - $results = []; - $countries = Injector::inst()->get(Locales::class)->getCountries(); - $countries = array_slice($countries, 0, $limit); - - foreach ($countries as $code => $name) { - $results[] = [ - 'code' => $code, - 'name' => $name, - ]; - } - - return $results; - } -} -``` - -Now let's try our query again. This time, notice that the IDE is telling us we're missing a required argument. -We need to add the argument to our query: - -```graphql -query { - readCountries(limit: 5) { - name - code - } -} -``` - -This works pretty well, but maybe it's a bit over the top to *require* the `limit` argument. We want to optimise -performance, but we also don't want to burden the developer with tedium like this. Let's give it a default value. - -```yml -# app/_graphql/schema.yml -queries: - 'readCountries(limit: Int = 20)': '[Country]' -``` - -Rebuild the schema and try the query again without adding a limit in the query. Notice that the IDE is no longer -yelling at you for a `limit` argument, but the result list is limited to the first 20 items. - -Let's take this a step further by turning this in to a proper [paginated result](adding_pagination). - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/05_adding_pagination.md b/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/05_adding_pagination.md deleted file mode 100644 index d207e783d..000000000 --- a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/05_adding_pagination.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -title: Adding pagination -summary: Add the pagination plugin to a generic query ---- -# Working with generic types - -[CHILDREN asList] - -## Adding pagination - -So far in this section we've created a simple generic query for a `Country` type called `readCountries` that takes a -`limit` argument. - -```graphql -query { - readCountries(limit: 5) { - name - code - } -} -``` - -Let's take this a step further and paginate it using a plugin. - -### The `paginate` plugin - -Since pagination is a fairly common task, we can take advantage of some reusable code here and just add a generic -plugin for paginating. - -> [!WARNING] -> If you're paginating a `DataList`, you might want to consider using models with read operations (instead of declaring -> them as generic types with generic queries), which paginate by default using the `paginateList` plugin. -> You can use generic typing and follow the below instructions too but it requires code that, for `DataObject` models, -> you get for free. - -Let's add the plugin to our query: - -```yml -# app/_graphql/schema.yml -queries: - readCountries: - type: '[Country]' - plugins: - paginate: {} -``` - -Right now the plugin will add the necessary arguments to the query, and update the return types. But -we still need to provide this generic plugin a way of actually limiting the result set, so we need a resolver. - -```yml -# app/_graphql/schema.yml -queries: - readCountries: - type: '[Country]' - plugins: - paginate: - resolver: ['App\GraphQL\Resolver\MyResolver', 'paginateCountries'] -``` - -Let's write that resolver code now: - -```php -namespace App\GraphQL\Resolver; - -use Closure; -use SilverStripe\GraphQL\Schema\Plugin\PaginationPlugin; - -class MyResolver -{ - public static function paginateCountries(array $context): Closure - { - $maxLimit = $context['maxLimit']; - return function (array $countries, array $args) use ($maxLimit) { - $offset = $args['offset']; - $limit = $args['limit']; - $total = count($countries); - if ($limit > $maxLimit) { - $limit = $maxLimit; - } - - $limitedList = array_slice($countries, $offset, $limit); - - return PaginationPlugin::createPaginationResult($total, $limitedList, $limit, $offset); - }; - } -} -``` - -A couple of things are going on here: - -- Notice the new design pattern of a **context-aware resolver**. Since the plugin is configured with a `maxLimit` -parameter, we need to get this information to the function that is ultimately used in the schema. Therefore, -we create a dynamic function in a static method by wrapping it with context. It's kind of like a decorator. -- As long as we can do the work of counting and limiting the array, the [`PaginationPlugin`](api:SilverStripe\GraphQL\Schema\Plugin\PaginationPlugin) -can handle the rest. It will return an array including `edges`, `nodes`, and `pageInfo`. - -Rebuild the schema and test it out: - -`vendor/bin/sake dev/graphql/build schema=default` - -```graphql -query { - readCountries(limit:3, offset:4) { - nodes { - name - } - } -} -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/06_adding_descriptions.md b/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/06_adding_descriptions.md deleted file mode 100644 index ebc87012f..000000000 --- a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/06_adding_descriptions.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Adding descriptions -summary: Add descriptions to just about anything in your schema to improve your developer experience ---- -# Working with generic types - -[CHILDREN asList] - -## Adding descriptions - -One of the great features of a schema-backed API is that it is self-documenting. If you use -the [`silverstripe/graphql-devtools`](https://github.com/silverstripe/silverstripe-graphql-devtools) -module you can see the documentation by navigating to /dev/graphql/ide in your browser anc clicking -on "DOCS" on the right. - -Many API developers choose to maximise the benefit of this by adding descriptions to some or -all of the components of their schema. - -The trade-off for using descriptions is that the YAML configuration becomes a bit more verbose. - -Let's add some descriptions to our types and fields. - -```yml -# app/_graphql/schema.yml -types: - Country: - description: A record that describes one of the world's sovereign nations - fields: - code: - type: String! - description: The unique two-letter country code - name: - type: String! - description: The canonical name of the country, in English -``` - -We can also add descriptions to our query arguments. We'll have to remove the inline argument -definition to do that. - -```yml -# app/_graphql/schema.yml -queries: - readCountries: - type: '[Country]' - description: Get all the countries in the world - args: - limit: - type: Int = 20 - description: The limit that is applied to the result set -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/07_enums_unions_and_interfaces.md b/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/07_enums_unions_and_interfaces.md deleted file mode 100644 index b6f44ef29..000000000 --- a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/07_enums_unions_and_interfaces.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -title: Enums, unions, and interfaces -summary: Add some non-object types to your schema ---- -# Working with generic types - -[CHILDREN asList] - -## Enums, unions, and interfaces - -In more complex schemas, you may want to define types that aren't simply a list of fields, or -"object types." These include enums, unions and interfaces. - -### Enum types - -Enum types are simply a list of string values that are possible for a given field. They are -often used in arguments to queries, such as `{sort: DESC}`. - -It's very easy to add enum types to your schema. Just use the `enums` section of the config. - -```yml -# app/_graphql/schema.yml -enums: - SortDirection: - values: - DESC: Descending order - ASC: Ascending order -``` - -### Interfaces - -An interface is a specification of fields that must be included on a type that implements it. -For example, an interface `Person` could include `firstName: String`, `surname: String`, and -`age: Int`. The types `Actor` and `Chef` would implement the `Person` interface, and therefore -querying for `Person` types can also give you `Actor` and `Chef` types in the result. Actors and -chefs must also have the `firstName`, `surname`, and `age` fields for such a query to work. - -To define an interface, use the `interfaces` section of the config. - -```yml -# app/_graphql/schema.yml -interfaces: - Person: - fields: - firstName: String! - surname: String! - age: Int! - resolveType: [ 'App\GraphQL\Resolver\MyResolver', 'resolvePersonType' ] -``` - -Interfaces must define a `resolve[Typename]Type` resolver method to inform the interface -which type it is applied to given a specific result. This method is non-discoverable and -must be applied explicitly. - -```php -namespace App\GraphQL\Resolver; - -use App\Model\Actor; -use App\Model\Chef; - -class MyResolver -{ - public static function resolvePersonType($object): string - { - if ($object instanceof Actor) { - return 'Actor'; - } - if ($object instanceof Chef) { - return 'Chef'; - } - } -} -``` - -### Union types - -A union type is used when a field can resolve to multiple types. For example, a query -for "Articles" could return a list containing both "Blog" and "NewsStory" types. - -To add a union type, use the `unions` section of the configuration. - -```yml -# app/_graphql/schema.yml -unions: - Article: - types: [ 'Blog', 'NewsStory' ] - typeResolver: [ 'App\GraphQL\Resolver\MyResolver', 'resolveArticleUnion' ] -``` - -Like interfaces, unions need to know how to resolve their types. These methods are also -non-discoverable and must be applied explicitly. - -```php -namespace App\GraphQL\Resolver; - -use App\Model\Article; - -class MyResolver -{ - public static function resolveArticleUnion(Article $object): string - { - if ($object->category === 'blogs') { - return 'Blog'; - } - if ($object->category === 'news') { - return 'NewsStory'; - } - } -} -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/index.md b/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/index.md deleted file mode 100644 index c6af3d5ea..000000000 --- a/en/02_Developer_Guides/19_GraphQL/03_working_with_generic_types/index.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: Working with generic types -summary: Break away from the magic of DataObject models and build types and queries from scratch. -icon: clipboard ---- - -# Working with generic types - -In this section of the documentation, we cover the fundamentals that are behind a lot of the magic that goes -into making `DataObject` types work. We'll create some types that are not based on DataObjects at all, and we'll -write some custom queries from the ground up. - -This is useful for situations where your data doesn't come from a `DataObject`, or where you have very specific -requirements for your GraphQL API that don't easily map to the schema of your `DataObject` classes. - -> [!NOTE] -> Just because we won't be using DataObjects in this example doesn't mean you can't do it - you can absolutely -> declare `DataObject` classes as generic types. You would lose a lot of the benefits of the `DataObject` model -> in doing so, but this lower level API may suit your needs for very specific use cases. - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/01_authentication.md b/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/01_authentication.md deleted file mode 100644 index 888d48324..000000000 --- a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/01_authentication.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: Authentication -summary: Ensure your GraphQL api is only accessible to provisioned users -icon: user-lock ---- - -# Security & best practices - -[CHILDREN asList] - -## Authentication - -Some Silverstripe CMS resources have permission requirements to perform CRUD operations -on, for example the `Member` object in the previous examples. - -If you are logged into the CMS and performing a request from the same session then -the same `Member` session is used to authenticate GraphQL requests, however if you -are performing requests from an anonymous/external application you may need to -authenticate before you can complete a request. - -> [!WARNING] -> Please note that when implementing GraphQL resources it is the developer's -> responsibility to ensure that permission checks are implemented wherever -> resources are accessed. - -### Default authentication - -The [`MemberAuthenticator`](api:SilverStripe\GraphQL\Auth\MemberAuthenticator) class is -configured as the default option for authentication, -and will attempt to use the current CMS `Member` session for authentication context. - -**If you are using the default session-based authentication, please be sure that you have -not disabled the [CSRF Middleware](csrf_protection). (It is enabled by default).** - -### HTTP basic authentication - -Silverstripe CMS has built-in support for [HTTP basic authentication](https://en.wikipedia.org/wiki/Basic_access_authentication). - -There is a [`BasicAuthAuthenticator`](api:SilverStripe\GraphQL\Auth\BasicAuthAuthenticator) -which can be configured for GraphQL that -will only activate when required. It is kept separate from the Silverstripe CMS -authenticator because GraphQL needs to use the successfully authenticated member -for CMS permission filtering, whereas the global [`BasicAuth`](api:SilverStripe\Security\BasicAuth) does not log the -member in or use it for model security. Note that basic auth will bypass MFA authentication -so if MFA is enabled it is not recommended that you also use basic auth for GraphQL. - -When using HTTP basic authentication, you can feel free to remove the [CSRF Middleware](csrf_protection), -as it just adds unnecessary overhead to the request. - -#### In graphiQL - -If you want to add basic authentication support to your GraphQL requests you can -do so by adding a custom `Authorization` HTTP header to your GraphiQL requests. - -If you are using the [GraphiQL macOS app](https://github.com/skevy/graphiql-app) -this can be done from "Edit HTTP Headers". - -The `/dev/graphql/ide` endpoint in [`silverstripe/graphql-devtools`](https://github.com/silverstripe/silverstripe-graphql-devtools) -does not support custom HTTP headers at this point. - -Your custom header should follow the following format: - -```text -# Key: Value -Authorization: Basic aGVsbG86d29ybGQ= -``` - -`Basic` is followed by a [base64 encoded](https://en.wikipedia.org/wiki/Base64) -combination of your username, colon and password. The above example is `hello:world`. - -**Note:** Authentication credentials are transferred in plain text when using HTTP -basic authentication. We strongly recommend using TLS for non-development use. - -Example: - -```bash -php -r 'echo base64_encode("hello:world");' -# aGVsbG86d29ybGQ= -``` - -### Defining your own authenticators - -You will need to define the class under `SilverStripe\GraphQL\Auth\Handlers.authenticators`. -You can optionally provide a `priority` number if you want to control which -authenticator is used when multiple are defined (higher priority returns first). - -Authenticator classes need to implement the [`AuthenticatorInterface`](api:SilverStripe\GraphQL\Auth\AuthenticatorInterface) -interface, which requires you to define an `authenticate()` method to return a `Member` or `false`, and -and an `isApplicable()` method which tells the [`Handler`](api:SilverStripe\GraphQL\Auth\Handler) whether -or not this authentication method -is applicable in the current request context (provided as an argument). - -Here's an example for implementing HTTP basic authentication: - -> [!WARNING] -> Note that basic authentication for GraphQL will bypass Multi-Factor Authentication (MFA) if that's enabled. Using basic authentication for GraphQL is considered insecure if you are using MFA. - -```yml -SilverStripe\GraphQL\Auth\Handler: - authenticators: - - class: SilverStripe\GraphQL\Auth\BasicAuthAuthenticator - priority: 10 -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/02_cors.md b/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/02_cors.md deleted file mode 100644 index 5ef567f25..000000000 --- a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/02_cors.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -title: Cross-Origin Resource Sharing (CORS) -summary: Ensure that requests to your API come from a whitelist of origins ---- - -# Security & best practices - -[CHILDREN asList] - -## Cross-Origin resource sharing (CORS) - -By default [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS) is disabled in the GraphQL Server. This can be easily enabled via YAML: - -```yml -SilverStripe\GraphQL\Controller: - cors: - Enabled: true -``` - -Once you have enabled CORS you can then control four new headers in the HTTP Response. - -1. **Access-Control-Allow-Origin.** - - This lets you define which domains are allowed to access your GraphQL API. There are - 4 options: - - - **Blank**: Deny all domains (except localhost) - - ```yml - Allow-Origin: - ``` - - - **'\*'**: Allow requests from all domains. - - ```yml - Allow-Origin: '*' - ``` - - - **Single Domain**: Allow requests from one specific external domain. - - ```yml - Allow-Origin: 'https://my.domain.com' - ``` - - - **Multiple Domains**: Allow requests from multiple specified external domains. - - ```yml - Allow-Origin: - - 'https://my.domain.com' - - 'https://your.domain.org' - ``` - -1. **Access-Control-Allow-Headers.** - - Access-Control-Allow-Headers is part of a CORS 'pre-flight' request to identify - what headers a CORS request may include. By default, the GraphQL server enables the - `Authorization` and `Content-Type` headers. You can add extra allowed headers that - your GraphQL may need by adding them here. For example: - - ```yml - Allow-Headers: 'Authorization, Content-Type, Content-Language' - ``` - - > [!WARNING] - > If you add extra headers to your GraphQL server, you will need to write a - > custom resolver function to handle the response. - -1. **Access-Control-Allow-Methods.** - - This defines the HTTP request methods that the GraphQL server will handle. By - default this is set to `GET, PUT, OPTIONS`. Again, if you need to support extra - methods you will need to write a custom resolver to handle this. For example: - - ```yml - Allow-Methods: 'GET, PUT, DELETE, OPTIONS' - ``` - -1. **Access-Control-Max-Age.** - - Sets the maximum cache age (in seconds) for the CORS pre-flight response. When - the client makes a successful OPTIONS request, it will cache the response - headers for this specified duration. If the time expires or the required - headers are different for a new CORS request, the client will send a new OPTIONS - pre-flight request to ensure it still has authorisation to make the request. - This is set to 86400 seconds (24 hours) by default but can be changed in YAML as - in this example: - - ```yml - Max-Age: 600 - ``` - -1. **Access-Control-Allow-Credentials.** - - When a request's credentials mode (Request.credentials) is "include", browsers - will only expose the response to frontend JavaScript code if the - Access-Control-Allow-Credentials value is true. - - The Access-Control-Allow-Credentials header works in conjunction with the - XMLHttpRequest.withCredentials property or with the credentials option in the - Request() constructor of the Fetch API. For a CORS request with credentials, - in order for browsers to expose the response to frontend JavaScript code, both - the server (using the Access-Control-Allow-Credentials header) and the client - (by setting the credentials mode for the XHR, Fetch, or Ajax request) must - indicate that they’re opting in to including credentials. - - This is set to empty by default but can be changed in YAML as in this example: - - ```yml - Allow-Credentials: 'true' - ``` - -### Apply a CORS config to all GraphQL endpoints - -```yml -## CORS Config -SilverStripe\GraphQL\Controller: - cors: - Enabled: true - Allow-Origin: 'https://silverstripe.org' - Allow-Headers: 'Authorization, Content-Type' - Allow-Methods: 'GET, POST, OPTIONS' - Allow-Credentials: 'true' - Max-Age: 600 # 600 seconds = 10 minutes. -``` - -### Apply a CORS config to a single GraphQL endpoint - -```yml -## CORS Config -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\Controller.default: - properties: - corsConfig: - Enabled: false -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/03_csrf_protection.md b/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/03_csrf_protection.md deleted file mode 100644 index 753825875..000000000 --- a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/03_csrf_protection.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: CSRF protection -summary: Protect destructive actions from cross-site request forgery ---- -# Security & best practices - -[CHILDREN asList] - -## CSRF tokens (required for mutations) - -Even if your GraphQL endpoints are behind authentication, it is still possible for unauthorised -users to access that endpoint through a [CSRF exploitation](https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)). This involves -forcing an already authenticated user to access an HTTP resource unknowingly (e.g. through a fake image), thereby hijacking the user's -session. - -In the absence of a token-based authentication system, like OAuth, the best countermeasure to this -is the use of a CSRF token for any requests that destroy or mutate data. - -By default, this module comes with a [`CSRFMiddleware`](api:SilverStripe\GraphQL\Middleware\CSRFMiddleware) -implementation that forces all mutations to check -for the presence of a CSRF token in the request. That token must be applied to a header named `X-CSRF-TOKEN`. - -In Silverstripe CMS, CSRF tokens are most commonly stored in the session as `SecurityID`, or accessed through -the [`SecurityToken`](api:SilverStripe\Security\SecurityToken) API, using `SecurityToken::inst()->getValue()`. - -Queries do not require CSRF tokens. - -### Disabling CSRF protection (for token-based authentication only) - -If you are using HTTP basic authentication or a token-based system like OAuth or [JWT](https://github.com/Firesphere/silverstripe-graphql-jwt), -you will want to remove the CSRF protection, as it just adds unnecessary overhead. You can do this by setting -the middleware to `false`. - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\QueryHandler\QueryHandlerInterface.default: - class: SilverStripe\GraphQL\QueryHandler\QueryHandler - properties: - Middlewares: - csrf: false -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/04_http_method_checking.md b/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/04_http_method_checking.md deleted file mode 100644 index 1a8c3cb16..000000000 --- a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/04_http_method_checking.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: Strict HTTP method checking -summary: Ensure requests are GET or POST ---- - -# Security & best practices - -[CHILDREN asList] - -## Strict HTTP method checking - -According to GraphQL best practices, mutations should be done over `POST`, while queries have the option -to use either `GET` or `POST`. By default, this module enforces the `POST` request method for all mutations. - -To disable that requirement, you can remove the [`HTTPMethodMiddleware`](api:SilverStripe\GraphQL\Middleware\HTTPMethodMiddleware) -from the [`QueryHandler`](api:SilverStripe\GraphQL\QueryHandler\QueryHandler). - -```yml -SilverStripe\GraphQL\QueryHandler\QueryHandlerInterface.default: - class: SilverStripe\GraphQL\QueryHandler\QueryHandler - properties: - Middlewares: - httpMethod: false -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/05_recursive_or_complex_queries.md b/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/05_recursive_or_complex_queries.md deleted file mode 100644 index 63cfb3f9d..000000000 --- a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/05_recursive_or_complex_queries.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: Recursive or complex queries -summary: Protecting against potentially malicious queries ---- - -# Security & best practices - -[CHILDREN asList] - -## Recursive or complex queries - -GraphQL schemas can contain recursive types and circular dependencies. Recursive or overly complex queries can take up a lot of resources, -and could have a high impact on server performance and even result in a denial of service if not handled carefully. - -Before parsing queries, if a query is found to have more than 500 nodes, it is rejected. While executing queries there is a default query depth limit of 15 for all schemas with no complexity limit. - -You can customise the node limit and query depth and complexity limits by setting the following configuration: - -```yml -# app/_config/graphql.yml ---- -After: 'graphql-schema-global' ---- -SilverStripe\GraphQL\Schema\Schema: - schemas: - '*': - config: - max_query_nodes: 250 # default 500 - max_query_depth: 20 # default 15 - max_query_complexity: 100 # default unlimited -``` - -> [!NOTE] -> For calculating the query complexity, every field in the query gets a default score 1 (including ObjectType nodes). Total complexity of the query is the sum of all field scores. - -You can also configure these settings for individual schemas. This allows you to fine-tune the security of your custom public-facing schema without affecting the security of the schema used in the CMS. To do so, either replace `'*'` with the name of your schema in the YAML configuration above, or set the values under the `config` key for your schema using preferred file structure as defined in [configuring your schema](../getting_started/configuring_your_schema/). For example: - -```yml -# app/_graphql/config.yml -max_query_nodes: 250 -max_query_depth: 20 -max_query_complexity: 100 -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/index.md b/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/index.md deleted file mode 100644 index ea935db92..000000000 --- a/en/02_Developer_Guides/19_GraphQL/04_security_and_best_practices/index.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Security & best practices -summary: A guide to keeping your GraphQL API secure and accessible -icon: user-lock ---- - -# Security and best practices - -In this section we'll cover several options you have for keeping your GraphQL API secure and compliant -with best practices. Some of these tools require configuration, while others come pre-installed. - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/05_plugins/01_overview.md b/en/02_Developer_Guides/19_GraphQL/05_plugins/01_overview.md deleted file mode 100644 index d41f389ba..000000000 --- a/en/02_Developer_Guides/19_GraphQL/05_plugins/01_overview.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -title: What are plugins? -summary: An overview of how plugins work with the GraphQL schema ---- - -# Plugins - -[CHILDREN asList] - -## What are plugins? - -Plugins are used to distribute reusable functionality across your schema. Some examples of commonly used plugins include: - -- Adding versioning arguments to versioned DataObjects -- Adding a custom filter/sort arguments to `DataObject` queries -- Adding a one-off `VerisionedStage` enum to the schema -- Ensuring `Member` is in the schema -- And many more... - -### Default plugins - -By default, all schemas ship with some plugins installed that will benefit most use cases: - -- The `DataObject` model (i.e. any `DataObject` based type) has: - - An `inheritance` plugin that builds the interfaces, unions, and merges ancestral fields. - - An `inheritedPlugins` plugin (a bit meta!) that merges plugins from ancestral types into descendants. - installed). -- The `read` and `readOne` operations have: - - A `canView` plugin for hiding records that do not pass a `canView()` check -- The `read` operation has: - - A `paginateList` plugin for adding pagination arguments and types (e.g. `nodes`) - -In addition to the above, the `default` schema specifically ships with an even richer set of default -plugins, including: - -- A `versioning` plugin that adds `version` fields to the `DataObject` type (if `silverstripe/versioned` is installed) -- A `readVersion` plugin (if `silverstripe/versioned` is installed) that allows versioned operations on -`read` and `readOne` queries. -- A `filter` plugin for filtering queries (adds a `filter` argument) -- A `sort` plugin for sorting queries (adds a `sort` argument) - -All of these are defined in the `modelConfig` section of the schema (see [configuring your schema](../getting_started/configuring_your_schema)). -For reference, see the GraphQL configuration in `silverstripe/admin`, which applies -these default plugins to the `admin` schema. - -#### Overriding default plugins - -You can override default plugins generically in the `modelConfig` section. - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - plugins: - inheritance: false # No `DataObject` models get this plugin unless opted into - operations: - read: - plugins: - paginateList: false # No `DataObject` models have paginated read operations unless opted into -``` - -You can override default plugins on your specific `DataObject` type and these changes will be inherited by descendants. - -```yml -# app/_graphql/models.yml -Page: - plugins: - inheritance: false -App\PageType\MyCustomPage: {} # now has no inheritance plugin -``` - -Likewise, you can do the same for operations: - -```yml -# app/_graphql/models.yml -Page: - operations: - read: - plugins: - readVersion: false -App\PageType\MyCustomPage: - operations: - read: true # has no readVersion plugin -``` - -### What plugins must do - -There isn't a huge API surface to a plugin. They just have to: - -- Implement at least one of several plugin interfaces -- Declare an identifier -- Apply themselves to the schema with the `apply(Schema $schema)` method -- Be registered with the [`PluginRegistry`](api:SilverStripe\GraphQL\Schema\Registry\PluginRegistry) - -### Available plugin interfaces - -Plugin interfaces are all found in the `SilverStripe\GraphQL\Schema\Interfaces` namespace - -- [`SchemaUpdater`](api:SilverStripe\GraphQL\Schema\Interfaces\SchemaUpdater): Make a one-off, context-free update to the schema -- [`QueryPlugin`](api:SilverStripe\GraphQL\Schema\Interfaces\QueryPlugin): Update a generic query -- [`MutationPlugin`](api:SilverStripe\GraphQL\Schema\Interfaces\MutationPlugin): Update a generic mutation -- [`TypePlugin`](api:SilverStripe\GraphQL\Schema\Interfaces\TypePlugin): Update a generic type -- [`FieldPlugin`](api:SilverStripe\GraphQL\Schema\Interfaces\FieldPlugin): Update a field on a generic type -- [`ModelQueryPlugin`](api:SilverStripe\GraphQL\Schema\Interfaces\ModelQueryPlugin): Update queries generated by a model, e.g. `readPages` -- [`ModelMutationPlugin`](api:SilverStripe\GraphQL\Schema\Interfaces\ModelMutationPlugin): Update mutations generated by a model, e.g. `createPage` -- [`ModelTypePlugin`](api:SilverStripe\GraphQL\Schema\Interfaces\ModelTypePlugin): Update types that are generated by a model -- [`ModelFieldPlugin`](api:SilverStripe\GraphQL\Schema\Interfaces\ModelFieldPlugin): Update a field on types generated by a model - -Wow, that's a lot of interfaces, right? This is owing mostly to issues around strict typing between interfaces, -and allows for a more expressive developer experience. Almost all of these interfaces have the same requirements, -just for different types. It's pretty easy to navigate if you know what you want to accomplish. - -### Registering plugins - -Plugins have to be registered to the `PluginRegistry` via the `Injector`. - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\Schema\Registry\PluginRegistry: - constructor: - - 'App\GraphQL\Plugin\MyPlugin' -``` - -### Resolver middleware and afterware - -The real power of plugins is the ability to distribute not just configuration across the schema, but -more importantly, *functionality*. - -Fields have their own resolvers already, so we can't really get into those to change -their functionality without a massive hack. This is where the idea of **resolver middleware** and -**resolver afterware** comes in really useful. - -**Resolver middleware** runs *before* the field's assigned resolver -**Resolver afterware** runs *after* the field's assigned resolver - -Middlewares and afterwares are pretty straightforward. They get the same `$args`, `$context`, and `$info` -parameters as the assigned resolver, but the first argument, `$result` is mutated with each resolver. - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/05_plugins/02_writing_a_simple_plugin.md b/en/02_Developer_Guides/19_GraphQL/05_plugins/02_writing_a_simple_plugin.md deleted file mode 100644 index 47db3aee8..000000000 --- a/en/02_Developer_Guides/19_GraphQL/05_plugins/02_writing_a_simple_plugin.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: Writing a simple plugin -summary: In this tutorial, we add a simple plugin for string fields ---- - -# Plugins - -[CHILDREN asList] - -## Writing a simple plugin - -For this example, we want all `String` fields to have a `truncate` argument that will limit the length of the string -in the response. - -Because it applies to fields, we'll want to implement the [`FieldPlugin`](api:SilverStripe\GraphQL\Schema\Interfaces\FieldPlugin) -interface for this. - -```php -namespace App\GraphQL\Plugin; - -use SilverStripe\GraphQL\Schema\Field\Field; -use SilverStripe\GraphQL\Schema\Interfaces\FieldPlugin; -use SilverStripe\GraphQL\Schema\Schema; - -class Truncator implements FieldPlugin -{ - public function getIdentifier(): string - { - return 'truncate'; - } - - public function apply(Field $field, Schema $schema, array $config = []) - { - $field->addArg('truncate', 'Int'); - } -} -``` - -Now we've added an argument to any field that uses the `truncate` plugin. This is good, but it really -doesn't save us a whole lot of time. The real value here is that the field will automatically apply the truncation. - -For that, we'll need to augment our plugin with some *afterware*. - -```php -namespace App\GraphQL\Plugin; - -use SilverStripe\GraphQL\Schema\Field\Field; -use SilverStripe\GraphQL\Schema\Interfaces\FieldPlugin; -use SilverStripe\GraphQL\Schema\Schema; - -class Truncator implements FieldPlugin -{ - public function apply(Field $field, Schema $schema, array $config = []) - { - // Sanity check - Schema::invariant( - $field->getType() === 'String', - 'Field %s is not a string. Cannot truncate.', - $field->getName() - ); - - $field->addArg('truncate', 'Int'); - $field->addResolverAfterware([static::class, 'truncate']); - } - - public static function truncate(string $result, array $args): string - { - $limit = $args['truncate'] ?? null; - if ($limit) { - return substr($result, 0, $limit); - } - - return $result; - } -} -``` - -Let's register the plugin: - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\Schema\Registry\PluginRegistry: - constructor: - - 'App\GraphQL\Plugin\Truncator' -``` - -And now we can apply it to any string field we want: - -```yml -# app/_graphql/types.yml -Country: - name: - type: String - plugins: - truncate: true -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/05_plugins/03_writing_a_complex_plugin.md b/en/02_Developer_Guides/19_GraphQL/05_plugins/03_writing_a_complex_plugin.md deleted file mode 100644 index 750d76fcc..000000000 --- a/en/02_Developer_Guides/19_GraphQL/05_plugins/03_writing_a_complex_plugin.md +++ /dev/null @@ -1,328 +0,0 @@ ---- -title: Writing a complex plugin -summary: In this tutorial, we'll create a plugin that affects models, queries, and input types ---- - -# Plugins - -[CHILDREN asList] - -## Writing a complex plugin - -For this example, we'll imagine that a lot of our DataObjects are geocoded, and this is ostensibly some kind of -`Extension` that adds lat/lon information to the `DataObject`, and maybe allows you to ask how close it is to -a given lat/lon pair. - -We want any queries using these DataObjects to be able to search within a radius of a given lat/lon. - -To do this, we'll need a few things: - -- DataObjects that are geocodable should always expose their lat/lon fields for GraphQL queries -- `read` operations for DataObjects that are geocodable should include a `within` parameter -- An input type for this lat/lon parameter should be globally available to the schema -- A resolver should automatically filter the result set by proximity. - -Let's get started. - -### Step 1: ensure `DataObject` models expose lat/lon fields - -Since we're dealing with `DataObject` models, we'll need to implement a [`ModelTypePlugin`](api:SilverStripe\GraphQL\Schema\Interfaces\ModelTypePlugin). - -```php -namespace App\GraphQL\Plugin; - -use App\Geo\GeocodableExtension; -use SilverStripe\GraphQL\Schema\Interfaces\ModelTypePlugin; -use SilverStripe\GraphQL\Schema\Schema; -use SilverStripe\GraphQL\Schema\Type\ModelType; -// ... - -class GeocodableModelPlugin implements ModelTypePlugin -{ - public function getIdentifier(): string - { - return 'geocode'; - } - - public function apply(ModelType $type, Schema $schema, array $config = []): void - { - $class = $type->getModel()->getSourceClass(); - - // sanity check that this is a DataObject - Schema::invariant( - is_subclass_of($class, DataObject::class), - 'The %s plugin can only be applied to types generated by %s models', - __CLASS__, - DataObject::class - ); - - // only apply the plugin to geocodable DataObjects - if (!ViewableData::has_extension($class, GeocodableExtension::class)) { - return; - } - - $type->addField('Lat') - ->addField('Lon'); - } -} -``` - -And register the plugin: - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\Schema\Registry\PluginRegistry: - constructor: - - 'App\GraphQL\Plugin\GeocodableModelPlugin' -``` - -Once we've [applied the plugin](#step-5-apply-the-plugins), all DataObjects that have the `GeocodableExtension` extension will be forced to expose their lat/lon fields. - -### Step 2: add a new parameter to the queries - -We want any `readXXX` query to include a `within` parameter if it's for a geocodable `DataObject`. -For this, we're going to implement [`ModelQueryPlugin`](api:SilverStripe\GraphQL\Schema\Interfaces\ModelQueryPlugin), -because this is for queries generated by a model. - -```php -namespace App\GraphQL\Plugin; - -// ... - -class GeocodableQueryPlugin implements ModelQueryPlugin -{ - public function getIdentifier(): string - { - return 'geocodableQuery'; - } - - public function apply(ModelQuery $query, Schema $schema, array $config = []): void - { - $class = $query->getModel()->getSourceClass(); - // Only apply to geocodable objects - if (!ViewableData::has_extension($class, GeocodableExtension::class)) { - return; - } - - $query->addArg('within', 'SearchRadiusInput'); - } -} -``` - -Register the new plugin - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\Schema\Registry\PluginRegistry: - constructor: - - 'App\GraphQL\Plugin\GeocodableModelPlugin' - - 'App\GraphQL\Plugin\GeocodableQueryPlugin' -``` - -Now after we [apply the plugins](#step-5-apply-the-plugins), our read queries will have a new parameter: - -```graphql -query readEvents(within: ...) -``` - -But we're not done yet! What is `SearchRadiusInput`? We haven't defined that yet. Ideally, we want our query -to look like this: - -```graphql -query { - readEvents(within: { - lat: 123.123456, - lon: 123.123456, - proximity: 500, - unit: METER - }) { - nodes { - title - lat - lon - } - } -} -``` - -### Step 3: adding an input type - -We'll need this `SearchRadiusInput` to be shared across queries. It's not specific to any `DataObject`. -For this, we can implement [`SchemaUpdater`](api:SilverStripe\GraphQL\Schema\Interfaces\SchemaUpdater). -For tidiness, let's just to this in the same `GeocodeableQuery` class, since they share concerns. - -```php -namespace App\GraphQL\Plugin; - -use SilverStripe\GraphQL\Schema\Interfaces\SchemaUpdater; -use SilverStripe\GraphQL\Schema\Type\Enum; -use SilverStripe\GraphQL\Schema\Type\InputType; -// ... - -class GeocodableQueryPlugin implements ModelQueryPlugin, SchemaUpdater -{ - // ... - - public static function updateSchema(Schema $schema): void - { - $unitType = Enum::create('Unit', [ - 'METER' => 'METER', - 'KILOMETER' => 'KILOMETER', - 'FOOT' => 'FOOT', - 'MILE' => 'MILE', - ]); - $radiusType = InputType::create('SearchRadiusInput') - ->setFields([ - 'lat' => 'Float!', - 'lon' => 'Float!', - 'proximity' => 'Int!', - 'unit' => 'Unit!', - ]); - $schema->addType($unitType); - $schema->addType($unitType); - } -} -``` - -So now we can run queries with these parameters, but we need to somehow apply it to the result set. - -### Step 4: add a resolver to apply the filter - -All these DataObjects have their own resolvers already, so we can't really get into those to change -their functionality without a massive hack. This is where the idea of **resolver middleware** and -**resolver afterware** comes in really useful. - -**Resolver middleware** runs *before* the operation's assigned resolver -**Resolver afterware** runs *after* the operation's assigned resolver - -Middlewares and afterwares are pretty straightforward. They get the same `$args`, `$context`, and `$info` -parameters as the assigned resolver, but the first argument, `$result` is mutated with each resolver. - -In this case, we're going to be filtering our `DataList` procedurally and transforming it into an array. -We need to know that things like filters and sort have already been applied, because they expect a `DataList` -instance. So we'll need to do this fairly late in the chain. Afterware makes the most sense. - -```php -namespace App\GraphQL\Plugin; - -use App\Geo\Proximity; -// ... - -class GeocodableQueryPlugin implements ModelQueryPlugin, SchemaUpdater -{ - // ... - - public function apply(ModelQuery $query, Schema $schema, array $config = []): void - { - $class = $query->getModel()->getSourceClass(); - // Only apply to geocodable objects - if (!ViewableData::has_extension($class, GeocodableExtension::class)) { - return; - } - - $query->addArg('within', 'SearchRadiusInput'); - $query->addResolverAfterware([static::class, 'applyRadius']); - } - - public static function applyRadius($result, array $args): array - { - $results = []; - $proximity = new Proximity($args['unit'], $args['lat'], $args['lon']); - foreach ($result as $record) { - if ($proximity->isWithin($args['proximity'], $record->Lat, $record->Lon)) { - $results[] = $record; - } - } - - return $results; - } -} -``` - -Looking good! - -But there's still one little gotcha. This is likely to be run after pagination has been executed, so our -`$result` parameter is probably an array of `edges`, `nodes`, etc. - -```php -// app/src/GraphQL/Resolver/MyResolver.php -namespace App\GraphQL\Resolver; - -use App\Geo\Proximity; - -class MyResolver -{ - public static function applyRadius($result, array $args) - { - $results = []; - // imaginary class - $proximity = new Proximity($args['unit'], $args['lat'], $args['lon']); - foreach ($result['nodes'] as $record) { - if ($proximity->isWithin($args['proximity'], $record->Lat, $record->Lon)) { - $results[] = $record; - } - } - - return [ - 'edges' => $results, - 'nodes' => $results, - 'pageInfo' => $result['pageInfo'], - ]; - } -} -``` - -If we added this plugin in middleware rather than afterware, -we could filter the result set by a list of `IDs` early on, which would allow us to keep -a `DataList` throughout the whole cycle, but this would force us to loop over an -unlimited result set, and that's never a good idea. - -> [!WARNING] -> If you've picked up on the inconsistency that the `pageInfo` property is now inaccurate, this is a long-standing -> issue with doing post-query filters. Ideally, any middleware that filters a `DataList` should do it at the query level, -> but that's not always possible. - -### Step 5: apply the plugins - -We can apply the plugins to queries and DataObjects one of two ways: - -- Add them on a case-by-case basis to our config -- Add them as default plugins so that we never have to worry about it. - -Let's look at each approach: - -#### Case-by-case - -```yml -# app/_graphql/models.yml -App\Model\Event: - plugins: - geocode: true - fields: - title: true - operations: - read: - plugins: - geocodeableQuery: true -``` - -This can get pretty verbose, so you might just want to register them as default plugins for all DataObjects -and their `read` operations. In this case we've already added logic within the plugin itself to skip -DataObjects that don't have the `GeoCodable` extension. - -#### Apply by default - -```yml -# apply the `DataObject` plugin -SilverStripe\GraphQL\Schema\DataObject\DataObjectModel: - default_plugins: - geocode: true -# apply the query plugin -SilverStripe\GraphQL\Schema\DataObject\ReadCreator: - default_plugins: - geocodableQuery: true -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/05_plugins/index.md b/en/02_Developer_Guides/19_GraphQL/05_plugins/index.md deleted file mode 100644 index 4d72382cf..000000000 --- a/en/02_Developer_Guides/19_GraphQL/05_plugins/index.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Plugins -summary: Learn what plugins are and how you can use them to extend your schema -icon: file-code ---- - -# Plugins - -Plugins play a critical role in distributing reusable functionality across your schema. They can apply to just about -everything loaded into the schema, including types, fields, queries, mutations, and even specifically to model types -and their fields and operations. - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/06_extending/adding_a_custom_model.md b/en/02_Developer_Guides/19_GraphQL/06_extending/adding_a_custom_model.md deleted file mode 100644 index 918d8d12b..000000000 --- a/en/02_Developer_Guides/19_GraphQL/06_extending/adding_a_custom_model.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: Adding a custom model -summary: Add a new class-backed type beyond DataObject ---- -# Extending the schema - -[CHILDREN asList] - -## Adding a custom model - -The only point of contact the `silverstripe/graphql` schema has with -the Silverstripe ORM specifically is through the `DataObjectModel` adapter -and its associated plugins. This is important, because it means you -can plug in any schema-aware class as a model, and it will be afforded -all the same features as DataObjects. - -It is, however, hard to imagine a model-driven type that isn't -related to an ORM, so we'll keep this section simple and just describe -what the requirements are rather than think up an outlandish example -of what a non-`DataObject` model might be. - -### SchemaModelInterface - -Models must implement the [`SchemaModelInterface`](api:SilverStripe\GraphQL\Schema\Interfaces\SchemaModelInterface), -which has a lot of methods to implement. Let's walk through them: - -- `getIdentifier(): string`: A unique identifier for this model type, e.g. `DataObject` -- `hasField(string $fieldName): bool`: Return true if `$fieldName` exists -on the model -- `getTypeForField(string $fieldName): ?string`: Given a field name, -infer the type. If the field doesn't exist, return `null` -- `getTypeName(): string`: Get the name of the type (i.e. based on -the source class) -- `getDefaultResolver(?array $context = []): ResolverReference`: -Get the generic resolver that should be used for types that are built -with this model. -- `getSourceClass(): string`: Get the name of the class that builds -the type, e.g. `MyDataObject` -- `getAllFields(): array`: Get all available fields on the object -- `getModelField(string $fieldName): ?ModelType`: For nested fields. -If a field resolves to another model (e.g. has_one), return that -model type. - -In addition, models may want to implement: - -- [`OperationProvider`](api:SilverStripe\GraphQL\Schema\Interfaces\OperationProvider) (if your model creates operations, like -read, create, etc) -- [`DefaultFieldsProvider`](api:SilverStripe\GraphQL\Schema\Interfaces\DefaultFieldsProvider) (if your model provides a default list -of fields, e.g. `id`) - -This is all a lot to take in out of context. A good exercise would be -to look through how [`DataObjectModel`](api:SilverStripe\GraphQL\Schema\DataObject\DataObjectModel) implements all these methods. - -### SchemaModelCreatorInterface - -Given a class name, create an instance of [`SchemaModelCreatorInterface`](api:SilverStripe\GraphQL\Schema\Interfaces\SchemaModelCreatorInterface). -This layer of abstraction is necessary because we can't assume that -all implementations of `SchemaModelCreatorInterface` will accept a class name in their -constructors. - -Implementors of this interface just need to be able to report -whether they apply to a given class and create a model given a -class name. - -Look at the [`ModelCreator`](api:SilverStripe\GraphQL\Schema\DataObject\ModelCreator) implementation -for a good example of how this works. - -### Registering your model creator - -Just add it to the registry: - -```yml -# app/_graphql/config.yml -modelCreators: - - 'SilverStripe\GraphQL\Schema\DataObject\ModelCreator' -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/06_extending/adding_a_custom_operation.md b/en/02_Developer_Guides/19_GraphQL/06_extending/adding_a_custom_operation.md deleted file mode 100644 index a06578902..000000000 --- a/en/02_Developer_Guides/19_GraphQL/06_extending/adding_a_custom_operation.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: Adding a custom operation -summary: Add a new operation for model types ---- -# Extending the schema - -[CHILDREN asList] - -## Adding a custom operation - -By default, we get basic operations for our models, like `read`, `create`, -`update`, and `delete`, but we can add to this list by creating -an implementation of [`OperationProvider`](api:SilverStripe\GraphQL\Schema\Interfaces\OperationProvider) and registering it. - -Let's build a new operation that **duplicates** DataObjects. - -```php -namespace App\GraphQL; - -use SilverStripe\GraphQL\Schema\Field\ModelMutation; -use SilverStripe\GraphQL\Schema\Interfaces\ModelOperation; -use SilverStripe\GraphQL\Schema\Interfaces\OperationCreator; -use SilverStripe\GraphQL\Schema\Interfaces\SchemaModelInterface; -use SilverStripe\GraphQL\Schema\SchemaConfig; - -class DuplicateCreator implements OperationCreator -{ - public function createOperation( - SchemaModelInterface $model, - string $typeName, - array $config = [] - ): ?ModelOperation { - $mutationName = 'duplicate' . ucfirst(SchemaConfig::pluralise($typeName)); - - return ModelMutation::create($model, $mutationName) - ->setType($typeName) - ->addArg('id', 'ID!') - ->setDefaultResolver([static::class, 'resolve']) - ->setResolverContext([ - 'dataClass' => $model->getSourceClass(), - ]); - } -} -``` - -We add **resolver context** to the mutation because we need to know -what class to duplicate, but we need to make sure we still have a -static function. - -The signature for resolvers with context is: - -```php -namespace App\GraphQL\Resolvers; - -use Closure; - -class MyResolver -{ - public static function resolve(array $context): Closure - { - // ... - } -} -``` - -We use the context to pass to a function that we'll create dynamically. -Let's add that now. - -```php -namespace App\GraphQL; - -use Closure; -// ... -use SilverStripe\ORM\DataObject; - -class DuplicateCreator implements OperationCreator -{ - // ... - - public static function resolve(array $context = []): Closure - { - $dataClass = $context['dataClass'] ?? null; - return function ($obj, array $args) use ($dataClass) { - if (!$dataClass) { - return null; - } - return DataObject::get_by_id($dataClass, $args['id']) - ->duplicate(); - }; - } -} -``` - -Now, just add the operation to the `DataObjectModel` configuration -to make it available to all `DataObject` types. - -```yml -# app/_graphql/config.yml -modelConfig: - DataObject: - operations: - duplicate: - class: 'App\GraphQL\DuplicateCreator' -``` - -And use it: - -```yml -# app/_graphql/models.yml -App\Model\MyDataObject: - fields: '*' - operations: - read: true - duplicate: true -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/06_extending/adding_middleware.md b/en/02_Developer_Guides/19_GraphQL/06_extending/adding_middleware.md deleted file mode 100644 index b7b1104c3..000000000 --- a/en/02_Developer_Guides/19_GraphQL/06_extending/adding_middleware.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: Adding middleware -summary: Add middleware to to extend query execution ---- -# Extending the schema - -[CHILDREN asList] - -## Adding middleware - -Middleware is any piece of functionality that is interpolated into -a larger process. A key feature of middleware is that it can be used -with other middlewares in sequence and not have to worry about the order -of execution. - -In `silverstripe/graphql`, middleware is used for query execution, -but could ostensibly be used elsewhere too if the API ever accomodates -such an expansion. - -> [!WARNING] -> The middleware API in the `silverstripe/graphql` module is separate from other common middleware -> APIs in Silverstripe CMS, such as `HTTPMiddleware`. The two are not interchangable. - -The signature for middleware (defined in [`QueryMiddleware`](api:SilverStripe\GraphQL\Middleware\QueryMiddleware)) looks like this: - -```php -use GraphQL\Type\Schema; - -public abstract function process(Schema $schema, string $query, array $context, array $vars, callable $next); -``` - -The return value should be [`ExecutionResult`](api:GraphQL\Executor\ExecutionResult) or an `array`. - -- `$schema`: The underlying [Schema](https://webonyx.github.io/graphql-php/schema-definition/) object. - Useful to inspect whether types are defined in a schema. -- `$query`: The raw query string. -- `$context`: An arbitrary array which holds information shared between resolvers. - Use implementors of [`ContextProvider`](api:SilverStripe\GraphQL\Schema\Interfaces\ContextProvider) to get and set - data, rather than relying on the array keys directly. -- `$vars`: An array of (optional) [Query Variables](https://graphql.org/learn/queries/#variables). -- `$next`: A callable referring to the next middleware in the chain - -Let's write a simple middleware that logs our queries as they come in. - -```php -namespace App\GraphQL\Middleware; - -use GraphQL\Type\Schema; -use SilverStripe\GraphQL\Middleware\QueryMiddleware; -use SilverStripe\GraphQL\QueryHandler\UserContextProvider; -// ... - -class LoggingMiddleware implements QueryMiddleware -{ - public function process(Schema $schema, string $query, array $context, array $vars, callable $next) - { - $member = UserContextProvider::get($context); - - Injector::inst()->get(LoggerInterface::class) - ->info(sprintf( - 'Query executed: %s by %s', - $query, - $member ? $member->Title : ''; - )); - - // Hand off execution to the next middleware - return $next($schema, $query, $context, $vars); - } -} -``` - -Now we can register the middleware with our query handler: - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\QueryHandler\QueryHandlerInterface.default: - class: SilverStripe\GraphQL\QueryHandler\QueryHandler - properties: - Middlewares: - logging: '%$App\GraphQL\Middleware\LoggingMiddleware' -``` diff --git a/en/02_Developer_Guides/19_GraphQL/06_extending/global_schema.md b/en/02_Developer_Guides/19_GraphQL/06_extending/global_schema.md deleted file mode 100644 index 6cd8ce7be..000000000 --- a/en/02_Developer_Guides/19_GraphQL/06_extending/global_schema.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: The global schema -summary: How to push modifications to every schema in the project ---- - -# Extending the schema - -[CHILDREN asList] - -## The global schema - -Developers of thirdparty modules that influence GraphQL schemas may want to take advantage -of the *global schema*. This is a pseudo-schema that will merge itself with all other schemas -that have been defined. A good use case is in the `silverstripe/versioned` module, where it -is critical that all schemas can leverage its schema modifications. - -The global schema is named `*`. - -```yml -# app/_config/graphql.yml -SilverStripe\GraphQL\Schema\Schema: - schemas: - '*': - enums: - VersionedStage: - DRAFT: DRAFT - LIVE: LIVE -``` - -### Further reading - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/06_extending/index.md b/en/02_Developer_Guides/19_GraphQL/06_extending/index.md deleted file mode 100644 index 692a36e6d..000000000 --- a/en/02_Developer_Guides/19_GraphQL/06_extending/index.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Extending the schema -summary: Add new functionality to the schema -icon: code ---- - -# Extending the schema - -In this section of the documentation, we'll look at some advanced -features for developers who want to extend their GraphQL server -using custom models, middleware, and new operations. - -[CHILDREN] diff --git a/en/02_Developer_Guides/19_GraphQL/07_tips_and_tricks.md b/en/02_Developer_Guides/19_GraphQL/07_tips_and_tricks.md deleted file mode 100644 index 19438aeb8..000000000 --- a/en/02_Developer_Guides/19_GraphQL/07_tips_and_tricks.md +++ /dev/null @@ -1,199 +0,0 @@ ---- -title: Tips & Tricks -summary: Miscellaneous useful tips for working with your GraphQL schema ---- - -# Tips & tricks - -## Debugging the generated code - -By default, the generated PHP code is put into obfuscated classnames and filenames to prevent poisoning the search -tools within IDEs. Without this, you can search for something like "Page" in your IDE and get both a generated GraphQL -type (probably not what you want) and a `SiteTree` subclass (more likely what you want) in the results and have no easy way -of differentiating between the two. - -When debugging, however, it's much easier if these classnames are human-readable. To turn on debug mode, add `DEBUG_SCHEMA=1` -to your environment file. The classnames and filenames in the generated code directory will then match their type names. - -> [!WARNING] -> Take care not to use `DEBUG_SCHEMA=1` as an inline environment variable to your build command, e.g. -> `DEBUG_SCHEMA=1 vendor/bin/sake dev/graphql/build` because any activity that happens at run time, e.g. querying the schema -> will fail, since the environment variable is no longer set. - -In live mode, full obfuscation kicks in and the filenames become unreadable. You can only determine the type they map -to by looking at the generated classes and finding the `// @type:` inline comment, e.g. `// @type:Page`. - -This obfuscation is handled by the [`NameObfuscator`](api:SilverStripe\GraphQL\Schema\Storage\NameObfuscator) interface. - -There are various implementations: - -- [`NaiveNameObfuscator`](api:SilverStripe\GraphQL\Schema\Storage\NaiveNameObfuscator): Filename/Classname === Type name (debug only) -- [`HybridNameObfuscator`](api:SilverStripe\GraphQL\Schema\Storage\HybridNameObfuscator): Filename/Classname is a mix of the typename and a md5 hash (default). -- [`HashNameObfuscator`](api:SilverStripe\GraphQL\Schema\Storage\HashNameObfuscator): Filename/Classname is a md5 hash of the type name (non-dev only). - -## Getting the type name for a model class - -Often times, you'll need to know the name of the type given a class name. There's a bit of context to this. - -### Getting the type name from within your app - -If you need the type name during normal execution of your app, e.g. to display in your UI, you can rely -on the cached typenames, which are persisted alongside your generated schema code. - -```php -use SilverStripe\GraphQL\Schema\SchemaBuilder; - -SchemaBuilder::singleton()->read('default')->getTypeNameForClass($className); -``` - -## Persisting queries - -A common pattern in GraphQL APIs is to store queries on the server by an identifier. This helps save -on bandwidth, as the client doesn't need to put a fully expressed query in the request body - they can use a -simple identifier. Also, it allows you to whitelist only specific query IDs, and block all other ad-hoc, -potentially malicious queries, which adds an extra layer of security to your API, particularly if it's public. - -To implement persisted queries, you need an implementation of the -[`PersistedQueryMappingProvider`](api:SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider) interface. -By default three are provided, which cover most use cases: - -- [`FileProvider`](api:SilverStripe\GraphQL\PersistedQuery\FileProvider): Store your queries in a flat JSON file on the local filesystem. -- [`HTTPProvider`](api:SilverStripe\GraphQL\PersistedQuery\HTTPProvider): Store your queries on a remote server and reference a JSON file by URL. -- [`JSONStringProvider`](api:SilverStripe\GraphQL\PersistedQuery\JSONStringProvider): Store your queries as hardcoded JSON - -### Configuring query mapping providers - -All of these implementations can be configured through `Injector`. - -> [!WARNING] -> Note that each schema gets its own set of persisted queries. In these examples, we're using the `default` schema. - -#### FileProvider - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider: - class: SilverStripe\GraphQL\PersistedQuery\FileProvider - properties: - schemaMapping: - default: '/var/www/project/query-mapping.json' -``` - -A flat file in the path `/var/www/project/query-mapping.json` should contain something like: - -```json -{"someUniqueID":"query{validateToken{Valid Message Code}}"} -``` - -> [!WARNING] -> The file path must be absolute. - -#### HTTPProvider - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider: - class: SilverStripe\GraphQL\PersistedQuery\HTTPProvider - properties: - schemaMapping: - default: 'https://www.example.com/myqueries.json' -``` - -A flat file at the URL `https://www.example.com/myqueries.json` should contain something like: - -```json -{"someUniqueID":"query{readMembers{Name+Email}}"} -``` - -#### JSONStringProvider - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider: - class: SilverStripe\GraphQL\PersistedQuery\HTTPProvider - properties: - schemaMapping: - default: '{"myMutation":"mutation{createComment($comment:String!){Comment}}"}' -``` - -The queries are hardcoded into the configuration. - -### Requesting queries by identifier - -To access a persisted query, simply pass an `id` parameter in the request in lieu of `query`. - -`GET https://www.example.com/graphql?id=someID` - -> [!WARNING] -> Note that if you pass `query` along with `id`, an exception will be thrown. - -## Query caching (caution: EXPERIMENTAL) - -The [`QueryCachingMiddleware`](api:SilverStripe\GraphQL\Middleware\QueryCachingMiddleware) class is -an experimental cache layer that persists the results of a GraphQL -query to limit unnecessary calls to the database. The query cache is automatically expired when any -`DataObject` that it relies on is modified. The entire cache will be discarded on `?flush` requests. - -To implement query caching, add the middleware to your `QueryHandler` - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\QueryHandler\QueryHandlerInterface.default: - class: SilverStripe\GraphQL\QueryHandler\QueryHandler - properties: - Middlewares: - cache: '%$SilverStripe\GraphQL\Middleware\QueryCachingMiddleware' -``` - -And you will also need to apply the [QueryRecorderExtension](api:SilverStripe\GraphQL\Extensions\QueryRecorderExtension) extension to all DataObjects: - -```yml -SilverStripe\ORM\DataObject: - extensions: - - SilverStripe\GraphQL\Extensions\QueryRecorderExtension -``` - -> [!WARNING] -> This feature is experimental, and has not been thoroughly evaluated for security. Use at your own risk. - -## Schema introspection {#schema-introspection} - -Some GraphQL clients such as [Apollo Client](https://www.apollographql.com/apollo-client) require some level of introspection -into the schema. The [`SchemaTranscriber`](api:SilverStripe\GraphQL\Schema\Services\SchemaTranscriber) -class will persist this data to a static file in an event -that is fired on completion of the schema build. This file can then be consumed by a client side library -like Apollo. The `silverstripe/admin` module is built to consume this data and expects it to be in a -web-accessible location. - -```json -{ - "data":{ - "__schema":{ - "types":[ - { - "kind":"OBJECT", - "name":"Query", - "possibleTypes":null - } - // etc ... - ] - } - } -} -``` - -By default, the file will be stored in `public/_graphql/`. Files are only generated for the `silverstripe/admin` module. - -If you need these types for your own uses, add a new handler: - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\EventDispatcher\Dispatch\Dispatcher: - properties: - handlers: - graphqlTranscribe: - on: [ graphqlSchemaBuild.mySchema ] - handler: '%$SilverStripe\GraphQL\Schema\Services\SchemaTranscribeHandler' -``` - -This handler will only apply to events fired in the `mySchema` context. diff --git a/en/02_Developer_Guides/19_GraphQL/08_architecture_diagrams.md b/en/02_Developer_Guides/19_GraphQL/08_architecture_diagrams.md deleted file mode 100644 index 69b0989ef..000000000 --- a/en/02_Developer_Guides/19_GraphQL/08_architecture_diagrams.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: Architecture Diagrams -summary: A visual overview of the architecture and design of silverstripe/graphql -icon: sitemap ---- - -# Architecture diagrams - -## A very high-level overview - -![A high-level overview of the lifecycle](../../_images/graphql/high-level-lifecycle.png) - -The schema is generated during a build step, which generates code generation artefacts. These artefacts are executed at request time, meaning the schema itself imposes no penalty on the response time. - -## The build process - -![A high-level view of the GraphQL build process](../../_images/graphql/build_process.png) - -- **`/dev/graphql/build`**: This is the command that builds the schema. It also runs as a side effect of `dev/build` as a fallback. It accepts a `schema` parameter if you only want to build one schema. - -- **Schema Factory**: This class is responsible for rebuilding a schema or fetching an existing one (i.e. as cached generated code) - -- **Schema**: The most central class that governs the composition of your GraphQL schema and all of the connected services. It is largely a value object hydrated by config files and executable PHP code. - -- **Plugins**: Plugins are the primary input for mutating the schema through thirdparty code. They can also be used in app code to augment core features, e.g. default resolvers for DataObjects. - -- **Storable Schema**: A value object that is agnostic of domain-specific entities like plugins and models, and just contains the finalised set of types, queries, mutations, interfaces, unions, and scalars. It cannot be mutated once created. - -- **Schema Storage**: By default, there is only one implementation of the schema storage service -- the code generator. It produces two artefacts that are accessed at request time: the schema config (a giant multi-dimensional array), and the schema code (a massive bank of classes that implement the `webonyx/graphql-php` library) - -## The request process - -![A high-level view of what happens during a GraphQL request](../../_images/graphql/request_process.png) - -There are two key processes that happen at request time. Although they're run in serial, they rely on state, so they're depicted above as separate paths. - -The controller receives the query as a request parameter and persists it as state. It then fetches the schema from the schema storage service (generated code). Then, the query is passed to a query handler service that runs the query through the generated schema code, into a stack of resolvers that execute in serial, much like a stack of middlewares, until finally the response is generated and sent down the wire. - -> [!NOTE] -> The concept of the "resolver stack" is illustrated later in this document. - -## Schema composition - -![A diagram of what makes up a GraphQL schema](../../_images/graphql/schema_composition.png) - -The `Schema` class is largely a value object that serves as the air traffic controller for the creation of a storable schema (i.e. generated code). Most of what it contains will be familiar to anyone with a basic understanding of GraphQL - types, mutations, queries, etc. The magic, however, is in its nonstandard components: models and config. - -Models are the layers of abstraction that create plain types and queries based on DataObjects. Imagine these few lines of config: - -```yml -App\Model\MyModel: - fields: - '*': true - operations: - read: true -``` - -It is the model's job to interpret what `*` or "all fields" means in that context (e.g. looking at `$db`, `$has_one`, etc). It also can create a read query for that `DataObject` with the simple `read: true` directive, and adding something `query readMyDataObjects` to the schema for you. Models are described in more detail below. There is also a lot more to learn about the model layer in the [Working with DataObjects](../working_with_DataObjects) section. - -The nonstandard "config" component here contains arbitrary directives, most of which influence the behaviour of models - for instance adding plugins and influencing how resolvers operate. - -The primary role of the `Schema` class is to create a "storable schema" - a readonly object that contains only standard GraphQL components. That is, all models have been transformed into plain types, queries, mutations, interfaces, etc., and the schema is ready to encode. - -## Models and model types - -![A diagram showing how GraphQL type schema for DataObjects is generated](../../_images/graphql/models.png) - -Model types are created by providing a class name to the schema. From there, it asks the `Model Creator` service to create a model for that class name. This may seem like an unnessary layer of abstraction, but in theory, models could be based on classes that are not DataObjects, and in such a case a new model creator would be required. - -The model type composes itself by interrogating the model, an implementation of `SchemaModelInterface`. This will almost always be `DataObjectModel`. The model is responsible for solving domain-specific problems pertaining to a Silverstripe project, including: - -- What type should be used for this field? -- Create an operation for this class, e.g. "read", "update" -- Add all the fields for this class -- How do I resolve this field? - -All model types eventually become plain GraphQL types when the `Schema` class creates a `StorableSchema` instance. - -## Plugins - -![A diagram showing a few of the plugin interfaces and what they are used for](../../_images/graphql/plugins.png) - -As explained in [available plugin interfaces](../plugins/overview#available-plugin-interfaces), there are a lot of interfaces available for implementing a plugin. This is because you will need different types passed into the `apply()` method depending on what the plugin is for. This is illustrated in the diagram above. - -## Resolver composition - -![A diagram showing how multiple resolvers can be used to get data for a single query](../../_images/graphql/resolver_composition.png) - -Injecting behaviour into resolvers is one of the main ways the schema can be customised. For instance, if you add a new argument to a query, the standard `DataObject` resolver will not know about it, so you'll want to write your own code to handle that argument. You could overwrite the entire resolver, but then you would lose key functionality from other plugins, like pagination, sort, and filtering. - -To this end, resolvers are a product of composition. Each bit of functionality is just another resolver in the "stack." The stack passes the result of the previous resolver to the next resolver, while the other three parameters, `$args, $context, $info` are immutable. - -This pattern allows, for instance, filter plugin to run `$obj = $obj->filter(...)` and pass this result to the next resolver. If that next resolver is responsible for pagination, it is not working with a filtered set of results and can get an accurate total count on the result set. - -### Resolver context - -![A diagram showing how some resolvers will be passed contextual information about the query, while others will not](../../_images/graphql/resolver_context.png) - -Sometimes, a resolver needs to be used in multiple contexts, for instance, a generic "read" resolver for a `DataObject` that simply runs `DataList::create($className)`. That `$className` parameter needs to come from somewhere. Normally we would use some kind of state on an instance, but because resolver methods must be static, we don't have that option. This gets really tricky. - -To solve this problem, we can use "resolver context". - -> [!NOTE] -> The word "context" is a bit overloaded here. This section has nothing to do with the `$context` parameter that is passed to all resolvers. - -When resolvers have context, they must be factories, or functions that return functions, using the following pattern: - -```php -namespace App\GraphQL\Resolvers; - -class MyResolver -{ - public static function resolve(array $resolverContext) - { - $someInfo = $resolverContext['foo']; - return function ($obj, $args, $context, $info) use ($someInfo) { - // ... - }; - } -} -``` - -As illustrated above, some resolvers in the stack can be provided context, while others may not. diff --git a/en/02_Developer_Guides/19_GraphQL/index.md b/en/02_Developer_Guides/19_GraphQL/index.md deleted file mode 100644 index f2b5dfdf1..000000000 --- a/en/02_Developer_Guides/19_GraphQL/index.md +++ /dev/null @@ -1,12 +0,0 @@ -# Silverstripe CMS GraphQL server - -GraphQL can be used as a content API layer for Silverstripe CMS to get data in and out of the content management system. - -> [!CAUTION] -> GraphQL is being removed from the admin section in CMS 6.0, so any customisations made -> to the `admin` schema will no longer work. -> GraphQL will still be available as an optional module in CMS 6.0 and still be used to create frontend schemas i.e. the `default` schema. - -For more information on GraphQL in general, visit its [documentation site](https://graphql.org). - -[CHILDREN includeFolders] diff --git a/en/08_Changelogs/6.0.0.md b/en/08_Changelogs/6.0.0.md index 84e5adcd4..7abfcbd1f 100644 --- a/en/08_Changelogs/6.0.0.md +++ b/en/08_Changelogs/6.0.0.md @@ -17,6 +17,7 @@ title: 6.0.0 (unreleased) - [`intervention/image` has been upgraded from v2 to v3](#intervention-image) - [Bug fixes](#bug-fixes) - [API changes](#api-changes) + - [GraphQL removed from the CMS](#graphql-removed) - [Most extension hook methods are now protected](#hooks-protected) - [Strict typing for `Factory` implementations](#factory-strict-typing) - [General changes](#api-general) @@ -278,6 +279,18 @@ This release includes a number of bug fixes to improve a broad range of areas. C ## API changes +### GraphQL removed from the CMS {#graphql-removed} + +> [!INFO] If you need to use GraphQL in your project for public-facing frontend schemas, you can still install and use the [`silverstripe/graphql`](https://github.com/silverstripe/silverstripe-graphql) module. + +GraphQL has been removed from the admin section of the CMS and is no longer installed when creating a new project using [`silverstripe/installer`](https://github.com/silverstripe/silverstripe-installer), or an existing project that uses [`silverstripe/recipe-cms`](https://github.com/silverstripe/recipe-cms). All existing functionality in the CMS that previously relied on GraphQL has been migrated to use regular Silverstripe CMS controllers instead. + +Any customisations made to the `admin` GraphQL schema will no longer work. There are extension hooks available on the new controller endpoints for read operations, for example [`AssetAdminOpen::apiRead()`](api:SilverStripe\AssetAdmin\Controller\AssetAdminOpen::apiRead()) that allow you to customise the JSON data returned. + +PHP code such as resolvers that were in [`silverstripe/asset-admin`](http://github.com/silverstripe/silverstripe-admin), [`silverstripe/cms`](https://github.com/silverstripe/silverstripe-cms) and [`silverstripe/versioned`](https://github.com/silverstripe/silverstripe-versioned) have been move to the [`silverstripe/graphql`](https://github.com/silverstripe/silverstripe-graphql) module and have had their namespace updated. The GraphQL yml config for the versioned module has also been copied over as that was previously enabled by default on all schemas. The GraphQL YAML configuration for the `silverstripe/asset-admin` and `silverstripe/cms` modules has not been moved as as that was only enabled on the admin schema. + +If your project does not have any custom GraphQL, after upgrading you may still have the old `.graphql-generated` and `public/_graphql` folders in your project. You can safely remove these folders. + ### Most extension hook methods are now protected {#hooks-protected} Core implementations of most extension hooks such as `updateCMSFields()` now have protected visibility. Formerly they had public visibility which meant they could be called directly which was not how they were intended to be used. Extension hook implementations are still able to be declared public in project code, though it is recommended that all extension hook methods are declared protected in project code to follow best practice.