diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..70bda3b --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,23 @@ +name: docs +on: + pull_request: ~ + push: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + +jobs: + doctor-rst: + runs-on: ubuntu-latest + name: Lint (DOCtor-RST) + steps: + - name: checkout + uses: actions/checkout@v7 + + - name: doctor-rst + uses: docker://oskarstark/doctor-rst:1.85.0 + with: + args: docs --short --error-format=github diff --git a/.gitignore b/.gitignore index 10e7a61..7291615 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /cache .phpunit.result.cache .phpstan-cache +.doctor-rst.cache diff --git a/.symfony.bundle.yaml b/.symfony.bundle.yaml new file mode 100644 index 0000000..4a8519e --- /dev/null +++ b/.symfony.bundle.yaml @@ -0,0 +1,8 @@ +# Configuration used by symfony.com to publish the docs of this bundle. +# See https://github.com/liip/LiipImagineBundle/blob/2.x/.symfony.bundle.yaml for the documented format. +branches: ["master"] +maintained_branches: ["master"] +doc_dir: "docs/" +current_branch: "master" +dev_branch: "master" +dev_branch_alias: "7.x" diff --git a/README.md b/README.md index 558adef..89e3659 100644 --- a/README.md +++ b/README.md @@ -4,117 +4,61 @@ # GraphQLite bundle -Symfony bundle for the `thecodingmachine/graphqlite` package. -It discovers your annotated controllers and types, builds the schema, exposes the `/graphql` endpoint through a PSR-7 -bridge (with optional upload handling), and keeps the Symfony request available as the GraphQL context. +Symfony bundle for [GraphQLite](https://graphqlite.thecodingmachine.io): build a GraphQL API by adding PHP attributes +(`#[Query]`, `#[Mutation]`, `#[Type]`, `#[Field]`...) to your classes. The bundle exposes the `/graphql` endpoint and +integrates GraphQLite with Symfony services, Security, Validator, file uploads and cache. -Part of the bundle docs: https://graphqlite.thecodingmachine.io/docs/symfony-bundle - -See [thecodingmachine/graphqlite](https://github.com/thecodingmachine/graphqlite). +**Documentation: [docs/index.rst](docs/index.rst)** ## Requirements - PHP 8.1+ -- Supports: - - Symfony 6.4/7.0/8.0 - - GraphQLite ^8 - -## Installation - -```bash -composer require thecodingmachine/graphqlite-bundle -``` - -Ensure the bundle is enabled (Symfony Flex does this automatically via `config/bundles.php` after `composer require`). - -### Configure routes - -Import the bundle routes to expose `/graphql`: - -```yaml -# config/routes/graphqlite.yaml -graphqlite_bundle: - resource: '@GraphQLiteBundle/Resources/config/routes.php' -``` - -### Configure namespaces - -Tell GraphQLite where to look for controllers and types: - -```yaml -# config/packages/graphqlite.yaml -graphqlite: - namespace: - controllers: App\\GraphQL\\Controller - types: - - App\\GraphQL\\Type - - App\\Entity -``` - -## Quickstart - -Create a controller with GraphQLite attributes: - -```php -/``) otherwise. + +The ``schema.auto_reload`` option (see :doc:`configuration`) decides when +the cache is refreshed: + +* ``true`` (default in ``dev``): GraphQLite checks your controller and type + files on every request and refreshes the schema when one of them changed. + Edit a class, reload, done; +* ``false`` (default in ``prod``): the schema is built once and kept until + the cache is cleared. This is the fastest mode. + +Deploying to Production +----------------------- + +Run the usual cache commands as part of your deployment: + +.. code-block:: terminal + + $ php bin/console cache:clear --env=prod + +The bundle registers its cache as a Symfony *cache clearer*, so +``cache:clear`` also clears the GraphQLite cache. + +.. caution:: + + APCu memory is separate for each PHP process pool: the console (CLI) cannot + clear the APCu cache of your web server (PHP-FPM, FrankenPHP...). When the + schema is cached in APCu, restart or reload PHP after deploying, as you + probably already do for OPcache. + +Performance Tips +---------------- + +* Keep the ``namespace`` options narrow: list only the namespaces that + contain GraphQL classes, rather than ``App\``. Fewer classes to scan means a + faster cache warm-up and a faster ``dev`` environment; +* avoid the "N+1 queries" problem when fetching related objects: use the + `prefetch method`_ of GraphQLite to load them in batch; +* fetch only what the client asked for with the `query plan`_; +* limit the cost of the queries clients can send with + ``maximum_query_complexity`` and ``maximum_query_depth`` (see + :doc:`security`). + +.. _`prefetch method`: https://graphqlite.thecodingmachine.io/docs/prefetch-method +.. _`query plan`: https://graphqlite.thecodingmachine.io/docs/query-plan diff --git a/docs/commands.rst b/docs/commands.rst new file mode 100644 index 0000000..97c5054 --- /dev/null +++ b/docs/commands.rst @@ -0,0 +1,29 @@ +Console Commands +================ + +Dumping the Schema +------------------ + +The ``graphqlite:dump-schema`` command prints the GraphQL schema built from +your code, in the GraphQL Schema Definition Language (SDL): + +.. code-block:: terminal + + $ php bin/console graphqlite:dump-schema + +Use the ``--output`` (or ``-o``) option to write it to a file: + +.. code-block:: terminal + + $ php bin/console graphqlite:dump-schema --output=schema.graphql + +Types and fields are sorted by name, so the output is stable. A few use cases: + +* check that a class is picked up by GraphQLite after changing the + ``namespace`` configuration; +* commit the schema file and review its changes in pull requests, to spot + breaking changes of your API; +* generate typed code for your frontend (TypeScript types, query hooks...) + with tools such as `GraphQL Code Generator`_. + +.. _`GraphQL Code Generator`: https://the-guild.dev/graphql/codegen diff --git a/docs/configuration.rst b/docs/configuration.rst new file mode 100644 index 0000000..011b4af --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,116 @@ +Configuration Reference +======================= + +All the options live under the ``graphqlite`` key. This is the full list, with +the default values: + +.. code-block:: yaml + + # config/packages/graphqlite.yaml + graphqlite: + namespace: + # Required. Namespace(s) of the classes with #[Query] and #[Mutation]. + # A string or a list of strings. + controllers: App\GraphQL\Controller\ + # Required. Namespace(s) of the classes with #[Type], #[ExtendType], + # #[Input] and #[Factory]. A string or a list of strings. + types: + - App\Entity\ + - App\GraphQL\Input\ + + schema: + # Check the controller and type files for changes on every request, + # so that an edit shows up without clearing the cache. + auto_reload: '%kernel.debug%' + + security: + # Add the "login" and "logout" mutations: "auto", "on" or "off". + enable_login: auto + # Add the "me" query: "auto", "on" or "off". + enable_me: auto + # The firewall used by the "login" mutation. + firewall_name: main + # Allow clients to read the schema (__schema and __type queries). + introspection: true + # Reject queries above this complexity. No default: when the + # option is omitted, the complexity of queries is not limited. + # maximum_query_complexity: 314 + # Reject queries nested deeper than this. No default: when the + # option is omitted, the depth of queries is not limited. + # maximum_query_depth: 42 + + debug: + # Add the exception message to the error sent to the client. + INCLUDE_DEBUG_MESSAGE: false + # Add the stack trace to the error sent to the client. + INCLUDE_TRACE: false + # Let every exception bubble up to Symfony. + RETHROW_INTERNAL_EXCEPTIONS: false + # Let exceptions that are not "client aware" bubble up to Symfony. + RETHROW_UNSAFE_EXCEPTIONS: true + +You can also print this reference with the Symfony console: + +.. code-block:: terminal + + $ php bin/console config:dump-reference graphqlite + +``namespace`` +------------- + +See :ref:`installation-namespaces`. + +``schema`` +---------- + +``auto_reload`` + GraphQLite builds the schema by reading the attributes of your classes, + and caches the result. When ``auto_reload`` is ``true``, it checks whether + the files changed on every request, which is convenient during + development but costs some time. When ``false``, the schema is cached until + you clear the cache. It follows ``kernel.debug`` by default, so it is + enabled in the ``dev`` environment and disabled in ``prod``. Set it + explicitly when an environment does not fit that rule, such as a staging + server running with debug enabled that should still serve a cached + schema. See :doc:`cache-and-performance`. + +``security`` +------------ + +See :doc:`security`. + +``debug`` +--------- + +These flags control how errors are reported. They are passed to the +`webonyx/graphql-php`_ library that runs the queries. + +By default: + +* exceptions that implement GraphQLite's ``ClientAware`` interface (like + ``GraphQLException``) are turned into an error in the GraphQL response, with + their message. They are meant for the client: "Product not found", + "Invalid price", etc.; +* all other exceptions are not caught: they bubble up to Symfony, which logs + them and shows its usual error page (or the exception page in ``dev``). This + way, an unexpected error never leaks details to API clients, and you still + get it in your logs. + +Read the GraphQLite `error handling`_ documentation to learn how to send +errors to clients. + +During development, you may prefer to see the error in the GraphQL response +instead: + +.. code-block:: yaml + + # config/packages/graphqlite.yaml + when@dev: + graphqlite: + debug: + INCLUDE_DEBUG_MESSAGE: true + INCLUDE_TRACE: true + RETHROW_UNSAFE_EXCEPTIONS: false + +.. _`webonyx/graphql-php`: https://webonyx.github.io/graphql-php/error-handling/ +.. _`error handling`: https://graphqlite.thecodingmachine.io/docs/error-handling diff --git a/docs/extending.rst b/docs/extending.rst new file mode 100644 index 0000000..3e6ea5b --- /dev/null +++ b/docs/extending.rst @@ -0,0 +1,117 @@ +Extending GraphQLite +==================== + +GraphQLite has extension points to change how PHP types are mapped, add +custom GraphQL types, or run code around every field. With the bundle, you +plug them in by registering a service with the right tag. + +Services Inside Your Fields +--------------------------- + +Before writing an extension, note that a field can already receive any +service with the ``#[Autowire]`` attribute of GraphQLite: + +.. code-block:: php + + use Symfony\Contracts\Translation\TranslatorInterface; + use TheCodingMachine\GraphQLite\Annotations\Autowire; + use TheCodingMachine\GraphQLite\Annotations\Field; + + #[Field] + public function getDescription(#[Autowire] TranslatorInterface $translator): string + { + return $translator->trans('product.description.'.$this->id); + } + +The service is resolved from its type, and the parameter does not appear in +the GraphQL schema. The bundle makes these services public automatically. +Type-hint an interface rather than a concrete class, so the implementation +can change. ``#[Autowire]`` can also fetch a service by its id, but GraphQLite +discourages it: it hides a dependency on the container. Read more in the +GraphQLite `autowiring`_ documentation. + +Service Tags +------------ + +===================================== ========================================================= =============== +Tag The service must be... Autoconfigured +===================================== ========================================================= =============== +``graphql.output_type`` a webonyx ``ObjectType`` (custom output type) yes +``graphql.root_type_mapper_factory`` a ``RootTypeMapperFactoryInterface`` yes +``graphql.type_mapper`` a ``TypeMapperInterface`` no +``graphql.type_mapper_factory`` a ``TypeMapperFactoryInterface`` no +``graphql.field_middleware`` a ``FieldMiddlewareInterface`` no +``graphql.parameter_middleware`` a ``ParameterMiddlewareInterface`` no +``graphql.queryprovider`` a ``QueryProviderInterface`` no +``graphql.queryprovider_factory`` a ``QueryProviderFactoryInterface`` no +===================================== ========================================================= =============== + +*Autoconfigured* tags are added for you when the service has +``autoconfigure: true`` (the default in ``config/services.yaml``). For the +others, add the tag yourself, either on the service: + +.. code-block:: yaml + + # config/services.yaml + services: + App\GraphQL\Middleware\LogFieldMiddleware: + tags: ['graphql.field_middleware'] + +or for every class implementing an interface: + +.. code-block:: yaml + + # config/services.yaml + services: + _instanceof: + TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface: + tags: ['graphql.field_middleware'] + +Custom Output Types +------------------- + +When an attribute-based type is not enough, write the GraphQL type by hand +with webonyx/graphql-php: + +.. code-block:: php + + // src/GraphQL/Type/BookType.php + namespace App\GraphQL\Type; + + use GraphQL\Type\Definition\ObjectType; + use GraphQL\Type\Definition\Type; + + final class BookType extends ObjectType + { + public function __construct() + { + parent::__construct([ + 'name' => 'Book', + 'fields' => [ + 'title' => Type::nonNull(Type::string()), + ], + ]); + } + } + +Being an ``ObjectType``, the service is tagged automatically. To tell +GraphQLite that this GraphQL type represents a PHP class (so a query returning +``App\Model\Book`` uses it), add the ``class`` attribute on the tag: + +.. code-block:: yaml + + # config/services.yaml + services: + App\GraphQL\Type\BookType: + tags: + - { name: 'graphql.output_type', class: 'App\Model\Book' } + +Learn more in the GraphQLite documentation: `custom types`_, +`field middlewares`_, `argument resolving`_ (parameter middlewares) and +`internals`_ (type mappers). + +.. _`autowiring`: https://graphqlite.thecodingmachine.io/docs/autowiring +.. _`custom types`: https://graphqlite.thecodingmachine.io/docs/custom-types +.. _`field middlewares`: https://graphqlite.thecodingmachine.io/docs/field-middlewares +.. _`argument resolving`: https://graphqlite.thecodingmachine.io/docs/argument-resolving +.. _`internals`: https://graphqlite.thecodingmachine.io/docs/internals diff --git a/docs/file-uploads.rst b/docs/file-uploads.rst new file mode 100644 index 0000000..7f16d0f --- /dev/null +++ b/docs/file-uploads.rst @@ -0,0 +1,55 @@ +File Uploads +============ + +GraphQL has no built-in support for files. Most clients and servers use the +`GraphQL multipart request specification`_ to send files along with a query. +The bundle supports it through the `ecodev/graphql-upload`_ library. Install +it, and that's all: + +.. code-block:: terminal + + $ composer require ecodev/graphql-upload + +The bundle detects the library and handles multipart requests on +``/graphql``. To receive a file, type-hint a parameter with the PSR-7 +``UploadedFileInterface``: + +.. code-block:: php + + // src/GraphQL/Controller/ProductController.php + namespace App\GraphQL\Controller; + + use Psr\Http\Message\UploadedFileInterface; + use TheCodingMachine\GraphQLite\Annotations\Mutation; + + final class ProductController + { + #[Mutation] + public function uploadProductPicture(int $productId, UploadedFileInterface $picture): bool + { + $picture->moveTo('/path/to/pictures/'.$productId.'.jpg'); + + return true; + } + } + +The parameter has the ``Upload`` GraphQL type. The client sends the file as a +variable: + +.. code-block:: text + + mutation ($picture: Upload!) { + uploadProductPicture(productId: 1, picture: $picture) + } + +Your GraphQL client must support the multipart request specification. See +the `list of compatible clients`_. + +.. caution:: + + Like any file sent by a user, validate the size and the type of the file + before storing it, and never trust the client file name. + +.. _`GraphQL multipart request specification`: https://github.com/jaydenseric/graphql-multipart-request-spec +.. _`ecodev/graphql-upload`: https://github.com/Ecodev/graphql-upload +.. _`list of compatible clients`: https://github.com/jaydenseric/graphql-multipart-request-spec#client diff --git a/docs/getting-started.rst b/docs/getting-started.rst new file mode 100644 index 0000000..52530d3 --- /dev/null +++ b/docs/getting-started.rst @@ -0,0 +1,322 @@ +Getting Started +=============== + +This tutorial builds a small product catalog API. At the end, you will be able +to list products, fetch one product and create new products, all through +GraphQL. + +It assumes the bundle is installed and configured with these namespaces (see +:doc:`installation`): + +.. code-block:: yaml + + # config/packages/graphqlite.yaml + graphqlite: + namespace: + controllers: App\GraphQL\Controller\ + types: + - App\Entity\ + - App\GraphQL\Input\ + +Step 1: Declare a Type +---------------------- + +A GraphQL API returns *objects* that have *fields*. In GraphQLite, a type is a +plain PHP class with the ``#[Type]`` attribute, and each public property or +method with the ``#[Field]`` attribute becomes a field. The GraphQL type of +each field comes from the PHP type: ``int`` becomes ``Int!``, ``string`` +becomes ``String!``, ``?float`` becomes ``Float`` (nullable), etc. + +.. fixture: tests/DocsFixtures/Entity/Product.php + +.. code-block:: php + + // src/Entity/Product.php + namespace App\Entity; + + use TheCodingMachine\GraphQLite\Annotations\Field; + use TheCodingMachine\GraphQLite\Annotations\Type; + + #[Type] + class Product + { + public function __construct( + #[Field] + public readonly int $id, + #[Field] + public readonly string $name, + #[Field] + public readonly int $priceInCents, + ) { + } + } + +Fields can also be methods (``#[Field] public function getLabel(): string``), +which is handy for computed values. + +.. note:: + + Exposing an entity directly as a GraphQL type keeps this tutorial short. + It is not required, though: many applications return dedicated classes + (DTOs, view models) instead, so that the API does not change every time + the storage does. + + The GraphQL type can also be declared outside of the data class, if you + prefer to keep the class free of GraphQL attributes. Point + ``#[Type(class: ...)]`` to the data class, and each field method receives + the object as first argument (add the namespace of this class to the + ``types`` option): + + .. code-block:: php + + // src/GraphQL/Type/ProductType.php + namespace App\GraphQL\Type; + + use App\Entity\Product; + use TheCodingMachine\GraphQLite\Annotations\Field; + use TheCodingMachine\GraphQLite\Annotations\Type; + + #[Type(class: Product::class)] + final class ProductType + { + #[Field] + public function name(Product $product): string + { + return $product->name; + } + } + + Read more in the GraphQLite `external type declaration`_ documentation. + +Step 2: Fetch the Data +---------------------- + +To keep this tutorial focused on GraphQL, the products are stored in memory. +In a real application, this class would load them from your storage, and the +rest of the tutorial would not change: + +.. fixture: tests/DocsFixtures/Repository/ProductRepository.php + +.. code-block:: php + + // src/Repository/ProductRepository.php + namespace App\Repository; + + use App\Entity\Product; + + final class ProductRepository + { + /** @var array */ + private array $products = []; + + private int $nextId = 1; + + public function __construct() + { + $this->add('Mug', 950); + $this->add('T-shirt', 1990); + } + + /** @return list */ + public function findAll(): array + { + return array_values($this->products); + } + + public function find(int $id): ?Product + { + return $this->products[$id] ?? null; + } + + public function add(string $name, int $priceInCents): Product + { + $product = new Product($this->nextId++, $name, $priceInCents); + + return $this->products[$product->id] = $product; + } + + public function remove(int $id): bool + { + if (!isset($this->products[$id])) { + return false; + } + unset($this->products[$id]); + + return true; + } + } + +Step 3: Write the Queries +------------------------- + +Queries are the entry points of the API. They are public methods with the +``#[Query]`` attribute, in a class of the ``controllers`` namespace. The +controller is a regular service, so it can get the repository through its +constructor: + +.. fixture: tests/DocsFixtures/GraphQL/Controller/ProductController.php + +.. code-block:: php + + // src/GraphQL/Controller/ProductController.php + namespace App\GraphQL\Controller; + + use App\Entity\Product; + use App\GraphQL\Input\CreateProductInput; + use App\Repository\ProductRepository; + use TheCodingMachine\GraphQLite\Annotations\Mutation; + use TheCodingMachine\GraphQLite\Annotations\Query; + + final class ProductController + { + public function __construct( + private readonly ProductRepository $productRepository, + ) { + } + + /** + * @return list + */ + #[Query] + public function products(): array + { + return $this->productRepository->findAll(); + } + + #[Query] + public function product(int $id): ?Product + { + return $this->productRepository->find($id); + } + + #[Mutation] + public function createProduct(CreateProductInput $input): Product + { + return $this->productRepository->add($input->name, $input->priceInCents); + } + } + +Let's look at what each method gives you: + +* ``products()`` returns a list. PHP has no typed arrays, so the + ``@return list`` annotation tells GraphQLite what is inside the + array: the GraphQL type is ``[Product!]!``; +* ``product(int $id)``: method arguments become GraphQL arguments. ``?Product`` + means the query returns ``null`` when the product does not exist; +* ``createProduct()`` is a ``#[Mutation]``: the GraphQL way to say "this + changes data". It takes an *input type*, described in the next step. + +Step 4: Accept Input +-------------------- + +Clients send data to a mutation using *input types*. A class with the +``#[Input]`` attribute and ``#[Field]`` properties is enough. GraphQLite +passes the values sent by the client to the constructor, matching them by +name, so the object can be immutable: + +.. fixture: tests/DocsFixtures/GraphQL/Input/CreateProductInput.php + +.. code-block:: php + + // src/GraphQL/Input/CreateProductInput.php + namespace App\GraphQL\Input; + + use TheCodingMachine\GraphQLite\Annotations\Field; + use TheCodingMachine\GraphQLite\Annotations\Input; + + #[Input] + final class CreateProductInput + { + public function __construct( + #[Field] + public readonly string $name, + #[Field] + public readonly int $priceInCents, + ) { + } + } + +The ``CreateProductInput`` object is then passed to ``createProduct()``. + +Step 5: Call the API +-------------------- + +Start the Symfony local web server (or any web server) and send a query to +the ``/graphql`` endpoint: + +.. code-block:: terminal + + $ curl -X POST http://127.0.0.1:8000/graphql \ + -H 'Content-Type: application/json' \ + -d '{"query": "{ products { id name priceInCents } }"}' + +The response contains exactly the fields you asked for: + +.. code-block:: json + + { + "data": { + "products": [ + {"id": 1, "name": "Mug", "priceInCents": 950}, + {"id": 2, "name": "T-shirt", "priceInCents": 1990} + ] + } + } + +From now on, queries are shown in GraphQL syntax only. The client sends them +in the ``query`` key of the JSON body, as above. Fetching a single product +with an argument: + +.. code-block:: text + + { + product(id: 2) { + name + } + } + +Creating a product with the mutation: + +.. code-block:: text + + mutation { + createProduct(input: {name: "Hoodie", priceInCents: 3990}) { + id + name + } + } + +.. code-block:: json + + { + "data": { + "createProduct": {"id": 3, "name": "Hoodie"} + } + } + +.. tip:: + + Typing queries in curl gets old quickly. Install a GraphQL IDE to explore + your schema with auto-completion: see :doc:`graphiql`. + +What's Next? +------------ + +You built a complete, typed API with four small classes. From here: + +* restrict who can call what in :doc:`security`; +* validate the user input in :doc:`validation`; +* review all the options in :doc:`configuration`. + +The GraphQLite documentation covers everything else you can do with your +types: `queries`_, `mutations`_, `type mapping`_, `input types`_, +`extending a type`_, `pagination`_, `error handling`_, etc. + +.. _`external type declaration`: https://graphqlite.thecodingmachine.io/docs/external-type-declaration +.. _`queries`: https://graphqlite.thecodingmachine.io/docs/queries +.. _`mutations`: https://graphqlite.thecodingmachine.io/docs/mutations +.. _`type mapping`: https://graphqlite.thecodingmachine.io/docs/type-mapping +.. _`input types`: https://graphqlite.thecodingmachine.io/docs/input-types +.. _`extending a type`: https://graphqlite.thecodingmachine.io/docs/extend-type +.. _`pagination`: https://graphqlite.thecodingmachine.io/docs/pagination +.. _`error handling`: https://graphqlite.thecodingmachine.io/docs/error-handling diff --git a/docs/graphiql.rst b/docs/graphiql.rst new file mode 100644 index 0000000..ac6bd81 --- /dev/null +++ b/docs/graphiql.rst @@ -0,0 +1,36 @@ +Exploring the API with GraphiQL +=============================== + +`GraphiQL`_ is an in-browser IDE for GraphQL: it reads your schema and offers +auto-completion, documentation and a query editor. The bundle does not ship +it, but works out of the box with `OverblogGraphiQLBundle`_: + +.. code-block:: terminal + + $ composer require --dev overblog/graphiql-bundle + +The bundle needs Twig (``composer require twig``) and a route. With Symfony +Flex, the recipe creates the route for you. Otherwise, add it to the ``dev`` +environment: + +.. code-block:: yaml + + # config/routes/graphiql.yaml + when@dev: + overblog_graphiql: + resource: '@OverblogGraphiQLBundle/Resources/config/routing.xml' + +Open ``/graphiql`` in your browser. GraphQLiteBundle tells GraphiQL to send +the queries to its ``/graphql`` endpoint: there is nothing else to configure. + +.. tip:: + + GraphiQL needs introspection to read the schema. If you disabled + ``introspection`` (see :doc:`security`), enable it in the ``dev`` + environment. + +You can also use any standalone GraphQL client (Altair, Insomnia, Postman, +etc.): point it to ``https://your-app.example.com/graphql``. + +.. _`GraphiQL`: https://github.com/graphql/graphiql +.. _`OverblogGraphiQLBundle`: https://github.com/overblog/GraphiQLBundle diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..7906963 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,58 @@ +GraphQLiteBundle +================ + +GraphQLiteBundle integrates `GraphQLite`_ into Symfony applications. + +GraphQLite builds a GraphQL API from your PHP code: you add attributes such as +``#[Query]``, ``#[Mutation]``, ``#[Type]`` and ``#[Field]`` to your classes, and +GraphQLite turns them into a GraphQL schema. There is no schema file to write +and keep in sync. + +The bundle takes care of the Symfony side: + +* it finds your GraphQL controllers and types in the namespaces you configure + and gets them from the service container (so autowiring just works); +* it exposes the ``/graphql`` endpoint; +* it connects GraphQLite to Symfony Security (``#[Logged]``, ``#[Right]``, + ``#[Security]``) and can add ready-made ``login``/``logout`` mutations and a + ``me`` query; +* it validates arguments with the Symfony Validator; +* it gives your code access to the Symfony ``Request`` and handles file uploads; +* it caches the schema and provides a command to dump it. + +Requirements +------------ + +* PHP 8.1 or higher; +* Symfony 6.4, 7.x or 8.x; +* GraphQLite 8. + +Documentation +------------- + +New to the bundle? Start with :doc:`installation` and follow the +:doc:`getting-started` tutorial: you will have a working API in a few minutes. + +.. toctree:: + :maxdepth: 1 + + installation + getting-started + configuration + security + request-context + file-uploads + validation + graphiql + extending + cache-and-performance + commands + troubleshooting + +This documentation covers what is specific to Symfony. Everything about +writing queries, mutations, types, input types, pagination, error handling, +etc. is described in the `GraphQLite documentation`_, and works the same way +with this bundle. + +.. _`GraphQLite`: https://graphqlite.thecodingmachine.io +.. _`GraphQLite documentation`: https://graphqlite.thecodingmachine.io/docs/ diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..1148d7a --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,125 @@ +Installation +============ + +Run this command to install the bundle: + +.. code-block:: terminal + + $ composer require thecodingmachine/graphqlite-bundle + +Applications that use `Symfony Flex`_ are ready to go: Flex enables the bundle +and creates two configuration files for you. Jump to +:ref:`installation-namespaces`. + +Applications without Symfony Flex +--------------------------------- + +Enable the bundle by adding it to the list of registered bundles: + +.. code-block:: php + + // config/bundles.php + return [ + // ... + TheCodingMachine\GraphQLite\Bundle\GraphQLiteBundle::class => ['all' => true], + ]; + +Then create the two files that Flex would have created. + +The route file exposes the ``/graphql`` endpoint: + +.. code-block:: yaml + + # config/routes/graphqlite.yaml + graphqlite_bundle: + resource: '@GraphQLiteBundle/Resources/config/routes.php' + +The configuration file tells the bundle where your code lives (see the next +section for its content): + +.. code-block:: yaml + + # config/packages/graphqlite.yaml + graphqlite: + namespace: + controllers: App\GraphQL\Controller\ + types: + - App\Entity\ + - App\GraphQL\Input\ + +.. _installation-namespaces: + +Configuring the Namespaces +-------------------------- + +GraphQLite does not scan your whole application. It only looks at the +namespaces listed in ``config/packages/graphqlite.yaml``: + +``controllers`` + The classes that declare queries and mutations (methods with + ``#[Query]`` or ``#[Mutation]``). They are called *controllers*, like + Symfony controllers, because they are the entry points of your API. + +``types`` + The classes that describe GraphQL types: classes with ``#[Type]``, + ``#[ExtendType]``, ``#[Input]`` or ``#[Factory]``. These can be your + entities, DTOs, or dedicated classes that describe the GraphQL type of + another class. + +Both options accept a single namespace or a list of namespaces. The Flex +recipe uses ``App\GraphqlController\`` for controllers and the whole ``App\`` +namespace for types. That works, but scanning everything is slower, so we +recommend listing only the namespaces that contain GraphQL classes: + +.. code-block:: yaml + + # config/packages/graphqlite.yaml + graphqlite: + namespace: + controllers: App\GraphQL\Controller\ + types: + - App\Entity\ + - App\GraphQL\Input\ + +This is the configuration used in the :doc:`getting-started` tutorial. + +.. caution:: + + Do not put GraphQL controllers in the ``App\Controller\`` namespace. + Symfony tags the classes of that namespace with + ``controller.service_arguments`` and then tries to autowire every argument + of their public methods. This breaks as soon as a query or mutation takes + an input type as argument. See :doc:`troubleshooting`. + +Controllers Are Services +------------------------ + +The bundle gets controllers (and services injected in your fields) from the +service container. With the default ``config/services.yaml`` of a Symfony +application, every class in ``src/`` is already a service with autowiring +enabled, so you have nothing to do: a controller can ask for any service in its +constructor. + +Entities annotated with ``#[Type]`` do not need to be services: they are the +objects returned by your queries. + +Checking the Installation +------------------------- + +The ``/graphql`` route must be registered: + +.. code-block:: terminal + + $ php bin/console debug:router graphqliteRoute + +And the bundle can print the GraphQL schema it built from your code: + +.. code-block:: terminal + + $ php bin/console graphqlite:dump-schema + +If your queries or types are missing from the output, check the namespaces in +``config/packages/graphqlite.yaml``. Then continue with the +:doc:`getting-started` tutorial. + +.. _`Symfony Flex`: https://symfony.com/doc/current/setup/flex.html diff --git a/docs/request-context.rst b/docs/request-context.rst new file mode 100644 index 0000000..7977901 --- /dev/null +++ b/docs/request-context.rst @@ -0,0 +1,65 @@ +The Request and the GraphQL Context +=================================== + +Most of the time, you don't need the HTTP request: GraphQLite reads the query +and its variables from it and calls your methods with the right arguments. +Sometimes you still need it, for instance to read a header or the client IP. + +Injecting the Request +--------------------- + +Type-hint a parameter with the Symfony ``Request`` in any query, mutation or +field. GraphQLite fills it with the current request, and the parameter does +not appear in the GraphQL schema: + +.. code-block:: php + + // src/GraphQL/Controller/ProductController.php + namespace App\GraphQL\Controller; + + use Symfony\Component\HttpFoundation\Request; + use TheCodingMachine\GraphQLite\Annotations\Query; + + final class ProductController + { + #[Query] + public function preferredLanguage(Request $request): ?string + { + return $request->getPreferredLanguage(['en', 'fr']); + } + } + +The GraphQL Context +------------------- + +In webonyx/graphql-php, the *context* is an object shared by all the resolvers +of a query. The bundle uses a +``TheCodingMachine\GraphQLite\Bundle\Context\SymfonyGraphQLContext``, which +implements ``SymfonyRequestContextInterface``: call ``getRequest()`` on it to +get the Symfony request. You need it only when writing low-level code that +receives the context, such as a custom field middleware or a custom +``ObjectType`` (see :doc:`extending`). + +How the Endpoint Works +---------------------- + +GraphQLite runs on top of webonyx/graphql-php, which works with `PSR-7`_ +requests. For each request on ``/graphql``, the bundle: + +#. converts the Symfony request into a PSR-7 request with the + `PSR-7 Bridge`_; +#. decodes a JSON body if needed (an invalid JSON body gets an error + response); +#. handles multipart file uploads (see :doc:`file-uploads`); +#. runs the query and returns a ``JsonResponse``. + +The endpoint accepts ``GET`` and ``POST`` requests, with the query and the +variables in the URL, in a JSON body or in a form body. Batched queries (a +JSON list of queries) are not enabled: such a request gets a ``400`` error. + +The HTTP status code depends on the result: ``200`` on success, and the +status code of the error otherwise (e.g. ``401`` or ``403`` for security +errors, ``400`` for an invalid query). + +.. _`PSR-7`: https://www.php-fig.org/psr/psr-7/ +.. _`PSR-7 Bridge`: https://symfony.com/doc/current/components/psr7.html diff --git a/docs/security.rst b/docs/security.rst new file mode 100644 index 0000000..d55f8f9 --- /dev/null +++ b/docs/security.rst @@ -0,0 +1,265 @@ +Security +======== + +The bundle connects GraphQLite to the `Symfony Security`_ component. Install +it if your application does not use it yet: + +.. code-block:: terminal + + $ composer require symfony/security-bundle + +GraphQLite does not authenticate users itself: your Symfony firewall does, +exactly like for the rest of your application (login form, ``json_login``, +API tokens, JWT, etc.). Make sure the ``/graphql`` path is covered by a +firewall, otherwise there is never a logged-in user in your queries. + +Restricting Access to Queries and Fields +---------------------------------------- + +Add these attributes to any query, mutation or field: + +``#[Logged]`` + Only logged-in users can call it. + +``#[Right('ROLE_ADMIN')]`` + Only users granted this role (or permission) can call it. The check is + done with Symfony's ``isGranted()``, so role hierarchy and `voters`_ apply. + +``#[Security("is_granted('ROLE_ADMIN') or is_granted('EDIT', product)")]`` + For anything more complex, an expression evaluated with the Symfony + ExpressionLanguage (``composer require symfony/expression-language``). + It can use ``is_granted()``, ``is_logged()``, the current ``user`` and the + method arguments, so your voters receive the actual object. + +For instance, deleting a product is reserved to administrators: + +.. fixture: tests/DocsFixtures/GraphQL/Controller/AdminProductController.php + +.. code-block:: php + + // src/GraphQL/Controller/AdminProductController.php + namespace App\GraphQL\Controller; + + use App\Repository\ProductRepository; + use TheCodingMachine\GraphQLite\Annotations\Logged; + use TheCodingMachine\GraphQLite\Annotations\Mutation; + use TheCodingMachine\GraphQLite\Annotations\Right; + + final class AdminProductController + { + public function __construct( + private readonly ProductRepository $productRepository, + ) { + } + + #[Mutation] + #[Logged] + #[Right('ROLE_ADMIN')] + public function deleteProduct(int $id): bool + { + return $this->productRepository->remove($id); + } + } + +An anonymous user gets an error, with the HTTP status code 401: + +.. code-block:: json + + { + "errors": [ + { + "message": "You need to be logged to access this field", + "locations": [{"line": 1, "column": 12}], + "path": ["deleteProduct"] + } + ] + } + +A logged-in user without ``ROLE_ADMIN`` gets a 403 error with the message +"You do not have sufficient rights to access this field". + +.. tip:: + + Instead of an error, you can return a default value with + ``#[FailWith(null)]``, or remove the field from the schema for users that + are not allowed to see it with ``#[HideIfUnauthorized]``. + +To get the current user as an argument of your method, use ``#[InjectUser]``. +All these attributes are described in the GraphQLite documentation: +`authentication and authorization`_ and `fine grained security`_. + +The "login", "logout" and "me" Operations +----------------------------------------- + +The bundle can add three operations to your schema, so that a JavaScript +application can log in through GraphQL itself: + +* a ``login(userName: String!, password: String!)`` mutation; +* a ``logout`` mutation; +* a ``me`` query that returns the current user (or ``null``). + +The ``me`` query is enabled automatically as soon as the security bundle is +installed. The ``login`` and ``logout`` mutations need a bit more: + +* a firewall named ``main`` (see ``firewall_name`` below) and a password + hasher; +* sessions enabled (``framework.session``): the logged-in user is stored in + the session, as with a classic login form. + +A minimal configuration looks like this: + +.. code-block:: yaml + + # config/packages/security.yaml + security: + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + providers: + app_user_provider: + entity: + class: App\Entity\User + property: email + firewalls: + main: + lazy: true + provider: app_user_provider + +The ``login`` mutation loads the user with the user provider of the firewall, +checks the password and stores the authenticated user in the session: + +.. code-block:: text + + mutation { + login(userName: "foo", password: "bar") { + userName + roles + } + } + +.. code-block:: json + + { + "data": { + "login": {"userName": "foo", "roles": ["ROLE_USER"]} + } + } + +The following requests (with the session cookie) are authenticated. The ``me`` +query returns the current user, or ``null`` for an anonymous visitor: + +.. code-block:: text + + { + me { + userName + roles + } + } + +And ``logout`` removes the user from the session: + +.. code-block:: text + + mutation { + logout + } + +Adding Fields to the User +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The bundle maps Symfony's ``UserInterface`` to a GraphQL type with two fields: +``userName`` (from ``getUserIdentifier()``) and ``roles``. To expose more +fields, add ``#[Type]`` to your user class: + +.. code-block:: php + + // src/Entity/User.php + namespace App\Entity; + + use Symfony\Component\Security\Core\User\UserInterface; + use TheCodingMachine\GraphQLite\Annotations\Field; + use TheCodingMachine\GraphQLite\Annotations\Type; + + #[Type] + class User implements UserInterface + { + // ... + + #[Field] + public function getEmail(): string + { + return $this->email; + } + } + +The ``me`` query returns a ``UserInterface``, so use an `inline fragment`_ to +read the extra fields: + +.. code-block:: text + + { + me { + userName + ... on User { + email + } + } + } + +Configuring the Operations +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + # config/packages/graphqlite.yaml + graphqlite: + security: + # "auto" (default): enabled when the requirements above are met + # "on": always enabled; an exception explains what is missing + # "off": disabled + enable_login: auto + enable_me: auto + # the firewall used by "login" to find the user provider + firewall_name: main + +Set ``enable_login`` to ``on`` while setting things up: instead of silently +skipping the mutations, the bundle tells you which requirement is missing. +Set it to ``off`` if your users log in another way (a JWT, a login form...). + +Protecting the API Itself +------------------------- + +GraphQL lets clients write their own queries, so a client can also write a +query that is very expensive to run. Three options help: + +.. code-block:: yaml + + # config/packages/graphqlite.yaml + graphqlite: + security: + # hide the schema from clients (__schema and __type queries) + introspection: false + # reject queries that request too many fields at once + maximum_query_complexity: 314 + # reject queries that nest too many levels deep + maximum_query_depth: 42 + +A common setup is to disable introspection in production only: + +.. code-block:: yaml + + # config/packages/graphqlite.yaml + when@prod: + graphqlite: + security: + introspection: false + +Read more about complexity in the GraphQLite `operation complexity`_ +documentation. + +.. _`Symfony Security`: https://symfony.com/doc/current/security.html +.. _`voters`: https://symfony.com/doc/current/security/voters.html +.. _`authentication and authorization`: https://graphqlite.thecodingmachine.io/docs/authentication-authorization +.. _`fine grained security`: https://graphqlite.thecodingmachine.io/docs/fine-grained-security +.. _`inline fragment`: https://graphql.org/learn/queries/#inline-fragments +.. _`operation complexity`: https://graphqlite.thecodingmachine.io/docs/operation-complexity diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst new file mode 100644 index 0000000..0fa78e5 --- /dev/null +++ b/docs/troubleshooting.rst @@ -0,0 +1,67 @@ +Troubleshooting +=============== + +My Query or Type Does Not Appear in the Schema +---------------------------------------------- + +* Check that the class is in one of the namespaces configured in + ``config/packages/graphqlite.yaml``: ``controllers`` for queries and + mutations, ``types`` for types, input types and factories; +* check that the controller is a service (it is, if it lives in ``src/`` with + the default ``config/services.yaml``); +* dump the schema to see what GraphQLite found: + ``php bin/console graphqlite:dump-schema``; +* in ``prod``, the schema is cached until you clear the cache (see + :doc:`cache-and-performance`). + +Error "Cannot Autowire Service" for an Input Type +------------------------------------------------- + +The full message looks like *Cannot autowire service "App\\...": argument +"$..." of method "..." is type-hinted "...", you should configure its value +explicitly*. Symfony is trying to create an input type as a service. This +happens when GraphQL controllers are in the namespace of Symfony controllers +(``App\Controller\`` by default): Symfony tags these classes with +``controller.service_arguments`` and tries to autowire every argument of +their public methods. + +Move your GraphQL controllers to another namespace (for instance +``App\GraphQL\Controller\``) and update ``namespace.controllers`` in +``config/packages/graphqlite.yaml``. + +The "login" Mutation or the "me" Query Is Missing +------------------------------------------------- + +In ``auto`` mode, these operations are silently skipped when one of their +requirements is missing: the security bundle for ``me``; the security bundle, +a firewall, a password hasher and sessions for ``login``. Set +``enable_login: on`` (or ``enable_me: on``) temporarily: the bundle then +throws an exception that tells you what is missing. See :doc:`security`. + +The Logged-in User Is Always Null +--------------------------------- + +The ``/graphql`` route must be covered by a firewall that authenticates the +user (session, token...). Check the ``firewalls`` section of +``config/packages/security.yaml``, and that your client sends the session +cookie or the token with each request. + +I Get an Error Page Instead of a GraphQL Error +---------------------------------------------- + +This is the expected behavior for exceptions that are not meant for the +client: they bubble up to Symfony, so they are logged and never leak to API +clients. Throw a ``GraphQLException`` (or any exception implementing +``ClientAware``) for errors the client should see. You can change this +behavior with the ``debug`` options (see :doc:`configuration`). + +Maximum Function Nesting Level Reached +-------------------------------------- + +The webonyx/graphql-php library uses a deep call stack. This error from +Xdebug does not mean that your code has an infinite loop. Increase the limit +in your Xdebug configuration: + +.. code-block:: ini + + xdebug.max_nesting_level=500 diff --git a/docs/validation.rst b/docs/validation.rst new file mode 100644 index 0000000..f33954e --- /dev/null +++ b/docs/validation.rst @@ -0,0 +1,116 @@ +Validation +========== + +GraphQL already checks the *shape* of the data: a client cannot send a string +where an ``Int!`` is expected. Business rules (a valid email, a positive +price...) are your job, and the bundle lets you use the `Symfony Validator`_ +for them. It comes with the bundle: there is nothing to install. + +Validating Objects +------------------ + +The recommended way is to put the constraints on your objects, as you would +in any Symfony application, validate them, and throw a +``ValidationFailedException`` when there are violations: + +.. code-block:: php + + // src/GraphQL/Input/CreateProductInput.php + namespace App\GraphQL\Input; + + use Symfony\Component\Validator\Constraints as Assert; + use TheCodingMachine\GraphQLite\Annotations\Field; + use TheCodingMachine\GraphQLite\Annotations\Input; + + #[Input] + final class CreateProductInput + { + public function __construct( + #[Field] + #[Assert\NotBlank] + public readonly string $name, + #[Field] + #[Assert\Positive] + public readonly int $priceInCents, + ) { + } + } + +.. code-block:: php + + // src/GraphQL/Controller/ProductController.php + namespace App\GraphQL\Controller; + + use App\Entity\Product; + use App\GraphQL\Input\CreateProductInput; + use Symfony\Component\Validator\Validator\ValidatorInterface; + use TheCodingMachine\GraphQLite\Annotations\Mutation; + use TheCodingMachine\GraphQLite\Validator\ValidationFailedException; + + final class ProductController + { + public function __construct( + private readonly ValidatorInterface $validator, + ) { + } + + #[Mutation] + public function createProduct(CreateProductInput $input): Product + { + ValidationFailedException::throwException($this->validator->validate($input)); + + // ... + } + } + +Each violation becomes an error in the GraphQL response, with the name of the +invalid field: + +.. code-block:: json + + { + "errors": [ + { + "message": "This value should be positive.", + "extensions": { + "category": "Validate", + "code": "778b7ae0-84d3-481a-9dec-35fdb64b1d78", + "field": "priceInCents" + } + } + ] + } + +Validating Arguments +-------------------- + +When an argument is not mapped to an object, add the ``#[Assertion]`` +attribute to the parameter: + +.. code-block:: php + + use Symfony\Component\Validator\Constraints as Assert; + use TheCodingMachine\GraphQLite\Annotations\Query; + use TheCodingMachine\GraphQLite\Validator\Annotations\Assertion; + + #[Query] + public function findUserByEmail( + #[Assertion(constraint: new Assert\Email())] + string $email, + ): ?User { + // ... + } + +The ``constraint`` option also accepts a list of constraints: +``#[Assertion(constraint: [new Assert\NotBlank(), new Assert\Email()])]``. + +.. note:: + + GraphQLite can also validate ``#[Input]`` objects automatically with an + "input type validator", but the bundle does not configure one. Validate + input objects in your mutation as shown above. + +The GraphQLite `validation`_ documentation has more details. + +.. _`Symfony Validator`: https://symfony.com/doc/current/validation.html +.. _`validation`: https://graphqlite.thecodingmachine.io/docs/validation diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 33d0e37..960f286 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -15,7 +15,7 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('graphqlite'); $rootNode = $treeBuilder->getRootNode(); - $rootNode->info('Read more about GraphQLite available options at: https://graphqlite.thecodingmachine.io/docs/symfony-bundle') + $rootNode->info('Read more about GraphQLite bundle options at: https://github.com/thecodingmachine/graphqlite-bundle/blob/master/docs/configuration.rst') ->children() ->arrayNode('namespace') ->children() diff --git a/tests/DocsExamplesTest.php b/tests/DocsExamplesTest.php new file mode 100644 index 0000000..d35a467 --- /dev/null +++ b/tests/DocsExamplesTest.php @@ -0,0 +1,319 @@ +" comment must match that fixture file; + * - every bundle configuration example must be valid. + */ +class DocsExamplesTest extends TestCase +{ + private const DOCS_NAMESPACE = 'TheCodingMachine\\GraphQLite\\Bundle\\Tests\\DocsFixtures\\'; + + public function testGettingStartedProductsQuery(): void + { + $result = $this->query($this->bootKernel(), '{ + products { + id + name + priceInCents + } +}'); + + $this->assertSame([ + 'data' => [ + 'products' => [ + ['id' => 1, 'name' => 'Mug', 'priceInCents' => 950], + ['id' => 2, 'name' => 'T-shirt', 'priceInCents' => 1990], + ], + ], + ], $result); + } + + public function testGettingStartedProductQuery(): void + { + $result = $this->query($this->bootKernel(), '{ + product(id: 2) { + name + } +}'); + + $this->assertSame(['data' => ['product' => ['name' => 'T-shirt']]], $result); + } + + public function testGettingStartedCreateProductMutation(): void + { + $result = $this->query($this->bootKernel(), 'mutation { + createProduct(input: {name: "Hoodie", priceInCents: 3990}) { + id + name + } +}'); + + $this->assertSame(['data' => ['createProduct' => ['id' => 3, 'name' => 'Hoodie']]], $result); + } + + public function testSecurityLogin(): void + { + $kernel = $this->bootKernel(); + $kernel->getContainer()->set('session', new Session(new MockArraySessionStorage())); + + $result = $this->query($kernel, 'mutation { + login(userName: "foo", password: "bar") { + userName + roles + } +}'); + + $this->assertSame(['data' => ['login' => ['userName' => 'foo', 'roles' => ['ROLE_USER']]]], $result); + } + + public function testSecurityMe(): void + { + $kernel = $this->bootKernel(); + $meQuery = '{ + me { + userName + roles + } +}'; + + $this->assertSame(['data' => ['me' => null]], $this->query($kernel, $meQuery)); + + $this->logIn($kernel, ['ROLE_USER']); + $this->assertSame(['data' => ['me' => ['userName' => 'foo', 'roles' => ['ROLE_USER']]]], $this->queryBypassingKernel($kernel, $meQuery)); + } + + public function testSecurityLogout(): void + { + $kernel = $this->bootKernel(); + $kernel->getContainer()->set('session', new Session(new MockArraySessionStorage())); + + $this->assertSame(['data' => ['logout' => true]], $this->query($kernel, 'mutation { + logout +}')); + } + + public function testSecurityDeleteProductRequiresLogin(): void + { + $result = $this->query($this->bootKernel(), 'mutation { + deleteProduct(id: 1) +}'); + + $this->assertSame('You need to be logged to access this field', $result['errors'][0]['message']); + } + + public function testSecurityDeleteProductRequiresRight(): void + { + $kernel = $this->bootKernel(); + $mutation = 'mutation { + deleteProduct(id: 1) +}'; + + $this->logIn($kernel, ['ROLE_USER']); + $result = $this->queryBypassingKernel($kernel, $mutation); + $this->assertSame('You do not have sufficient rights to access this field', $result['errors'][0]['message']); + + $this->logIn($kernel, ['ROLE_ADMIN']); + $this->assertSame(['data' => ['deleteProduct' => true]], $this->queryBypassingKernel($kernel, $mutation)); + } + + /** + * @dataProvider provideSnippets + */ + public function testSnippetMatchesFixture(string $rstFile, string $fixtureFile, string $snippet): void + { + $fixturePath = __DIR__.'/../'.$fixtureFile; + $this->assertFileExists($fixturePath, sprintf('%s references a missing fixture.', $rstFile)); + + $fixture = file_get_contents($fixturePath); + $fixture = preg_replace('/^<\?php\s*/', '', $fixture); + $fixture = str_replace(self::DOCS_NAMESPACE, 'App\\', $fixture); + + $this->assertSame( + $this->normalize($fixture), + $this->normalize($snippet), + sprintf('The code-block of %s for %s is out of sync with the fixture.', $rstFile, $fixtureFile), + ); + } + + /** + * @return iterable + */ + public static function provideSnippets(): iterable + { + foreach (glob(__DIR__.'/../docs/*.rst') as $rstPath) { + $rstFile = 'docs/'.basename($rstPath); + $lines = explode("\n", file_get_contents($rstPath)); + $count = count($lines); + + for ($i = 0; $i < $count; $i++) { + if (!preg_match('/^\.\. fixture: (\S+)$/', $lines[$i], $matches)) { + continue; + } + + // Find the code-block directive following the fixture comment. + $j = $i + 1; + while ($j < $count && trim($lines[$j]) === '') { + $j++; + } + if ($j >= $count || !preg_match('/^\.\. code-block:: php$/', $lines[$j])) { + throw new \LogicException(sprintf('%s:%d: ".. fixture:" must be followed by a ".. code-block:: php".', $rstFile, $i + 1)); + } + + yield $rstFile.' -> '.$matches[1] => [$rstFile, $matches[1], self::readCodeBlock($lines, $j)]; + } + } + } + + /** + * Every bundle configuration shown in the docs must be accepted by the configuration tree of the bundle. + * + * @dataProvider provideBundleConfigs + */ + public function testBundleConfigIsValid(string $yaml): void + { + $config = (new ParameterBag(['kernel.debug' => true]))->resolveValue(Yaml::parse($yaml)); + + // Partial examples (only "security", only "debug"...) are merged with the required namespaces. + $configs = [['namespace' => ['controllers' => 'App\\GraphQL\\Controller\\', 'types' => 'App\\Entity\\']]]; + foreach ($config as $key => $value) { + if ($key === 'graphqlite') { + $configs[] = $value; + } elseif (str_starts_with($key, 'when@') && isset($value['graphqlite'])) { + $configs[] = $value['graphqlite']; + } + } + $this->assertGreaterThan(1, count($configs), 'The example does not contain a "graphqlite" key.'); + + (new Processor())->processConfiguration(new Configuration(), $configs); + } + + /** + * @return iterable + */ + public static function provideBundleConfigs(): iterable + { + foreach (glob(__DIR__.'/../docs/*.rst') as $rstPath) { + $rstFile = 'docs/'.basename($rstPath); + $lines = explode("\n", file_get_contents($rstPath)); + + foreach ($lines as $i => $line) { + if (!preg_match('/^\s*\.\. code-block:: yaml$/', $line)) { + continue; + } + + $yaml = self::readCodeBlock($lines, $i); + if (str_starts_with($yaml, '# config/packages/graphqlite.yaml')) { + yield $rstFile.':'.($i + 1) => [$yaml]; + } + } + } + } + + /** + * Returns the dedented content of the code-block whose directive is on line $directive. + * + * @param list $lines + */ + private static function readCodeBlock(array $lines, int $directive): string + { + $directiveIndent = strlen($lines[$directive]) - strlen(ltrim($lines[$directive])); + $blockLines = []; + for ($j = $directive + 1, $count = count($lines); $j < $count; $j++) { + $lineIndent = strlen($lines[$j]) - strlen(ltrim($lines[$j])); + if (trim($lines[$j]) !== '' && $lineIndent <= $directiveIndent) { + break; + } + $blockLines[] = $lines[$j]; + } + + return trim(self::dedent($blockLines)); + } + + /** + * @param list $lines + */ + private static function dedent(array $lines): string + { + $indent = null; + foreach ($lines as $line) { + if (trim($line) === '') { + continue; + } + $lineIndent = strlen($line) - strlen(ltrim($line)); + $indent = $indent === null ? $lineIndent : min($indent, $lineIndent); + } + + return implode("\n", array_map(static fn (string $line): string => (string) substr($line, (int) $indent), $lines)); + } + + private function normalize(string $code): string + { + return trim(preg_replace('/[ \t]+$/m', '', $code)); + } + + private function bootKernel(): KernelInterface + { + $kernel = new GraphQLiteTestingKernel( + controllersNamespace: [self::DOCS_NAMESPACE.'GraphQL\\Controller\\'], + typesNamespace: [self::DOCS_NAMESPACE.'Entity\\', self::DOCS_NAMESPACE.'GraphQL\\Input\\'], + ); + $kernel->boot(); + + return $kernel; + } + + /** + * Sends the query to /graphql as a JSON body, like the curl example of the docs. + * + * @return array + */ + private function query(KernelInterface $kernel, string $query): array + { + $request = Request::create('/graphql', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['query' => $query])); + + return json_decode($kernel->handle($request)->getContent(), true); + } + + /** + * Going through the kernel would reboot the container and drop the token set by logIn(). + * + * @return array + */ + private function queryBypassingKernel(KernelInterface $kernel, string $query): array + { + $request = Request::create('/graphql', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['query' => $query])); + $response = $kernel->getContainer()->get(GraphQLiteController::class)->handleRequest($request); + + return json_decode($response->getContent(), true); + } + + /** + * @param list $roles + */ + private function logIn(KernelInterface $kernel, array $roles): void + { + $token = new UsernamePasswordToken(new InMemoryUser('foo', 'bar', $roles), 'main', $roles); + $kernel->getContainer()->get('security.untracked_token_storage')->setToken($token); + } +} diff --git a/tests/DocsFixtures/Entity/Product.php b/tests/DocsFixtures/Entity/Product.php new file mode 100644 index 0000000..f7454d7 --- /dev/null +++ b/tests/DocsFixtures/Entity/Product.php @@ -0,0 +1,21 @@ +productRepository->remove($id); + } +} diff --git a/tests/DocsFixtures/GraphQL/Controller/ProductController.php b/tests/DocsFixtures/GraphQL/Controller/ProductController.php new file mode 100644 index 0000000..4f66ea8 --- /dev/null +++ b/tests/DocsFixtures/GraphQL/Controller/ProductController.php @@ -0,0 +1,39 @@ + + */ + #[Query] + public function products(): array + { + return $this->productRepository->findAll(); + } + + #[Query] + public function product(int $id): ?Product + { + return $this->productRepository->find($id); + } + + #[Mutation] + public function createProduct(CreateProductInput $input): Product + { + return $this->productRepository->add($input->name, $input->priceInCents); + } +} diff --git a/tests/DocsFixtures/GraphQL/Input/CreateProductInput.php b/tests/DocsFixtures/GraphQL/Input/CreateProductInput.php new file mode 100644 index 0000000..0d3d8ed --- /dev/null +++ b/tests/DocsFixtures/GraphQL/Input/CreateProductInput.php @@ -0,0 +1,19 @@ + */ + private array $products = []; + + private int $nextId = 1; + + public function __construct() + { + $this->add('Mug', 950); + $this->add('T-shirt', 1990); + } + + /** @return list */ + public function findAll(): array + { + return array_values($this->products); + } + + public function find(int $id): ?Product + { + return $this->products[$id] ?? null; + } + + public function add(string $name, int $priceInCents): Product + { + $product = new Product($this->nextId++, $name, $priceInCents); + + return $this->products[$product->id] = $product; + } + + public function remove(int $id): bool + { + if (!isset($this->products[$id])) { + return false; + } + unset($this->products[$id]); + + return true; + } +} diff --git a/tests/Fixtures/config/services.yaml b/tests/Fixtures/config/services.yaml index 789b136..fff7eec 100644 --- a/tests/Fixtures/config/services.yaml +++ b/tests/Fixtures/config/services.yaml @@ -20,6 +20,10 @@ services: TheCodingMachine\GraphQLite\Bundle\Tests\NoSecurityBundleFixtures\: resource: '../../NoSecurityBundleFixtures/*' + TheCodingMachine\GraphQLite\Bundle\Tests\DocsFixtures\: + resource: '../../DocsFixtures/*' + exclude: '../../DocsFixtures/{Entity,GraphQL/Input}' + TheCodingMachine\Graphqlite\Bundle\Tests\Fixtures\Types\CustomBookType: tags: - { name: 'graphql.output_type', class: 'TheCodingMachine\GraphQLite\Bundle\Tests\Fixtures\Entities\Book' }