Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# AGENTS.md - GraphQL Bundle Development Guide

This document provides guidance for agentic coding systems operating on the GraphQLBundle repository.

## Build, Lint, and Test Commands

### Docker Setup (Recommended)
All testing and development should be done via Docker container to ensure consistency.

**Initialize Docker environment:**
```bash
docker compose up -d # Start containers
docker exec app git config --global --add safe.directory /var/www/html # Fix git permissions
docker exec -w /var/www/html app composer install # Install dependencies
```

### Core Commands (via Docker)
- **Run all tests**: `docker exec -w /var/www/html app ./vendor/bin/phpunit`
- **Run single test**: `docker exec -w /var/www/html app ./vendor/bin/phpunit --filter testDefaultConfigIsUsed`
- **Run tests in a directory**: `docker exec -w /var/www/html app ./vendor/bin/phpunit Tests/DependencyInjection/`
- **Install dependencies**: `docker exec -w /var/www/html app composer install`
- **Update dependencies**: `docker exec -w /var/www/html app composer update`
- **Code modernization**: `docker exec -w /var/www/html app ./vendor/bin/rector process`
- **Code modernization dry-run**: `docker exec -w /var/www/html app ./vendor/bin/rector process --dry-run`

**Cleanup:**
```bash
docker compose down # Stop and remove containers
```

### PHPUnit Configuration
- Config file: `phpunit.xml.dist`
- Test bootstrap: `vendor/autoload.php`
- Test directory: `Tests/`
- Coverage excluded: `Resources/`, `Tests/`, `vendor/`
- Docker container: `app`
- Working directory in container: `/var/www/html`

### Local Development Setup
For local development, the `99designs/graphql` package is configured to use the local path repository at `../GraphQL-php`. This allows testing the bundle with changes to the GraphQL library before pushing to the remote repository.

**Configuration details:**
- `docker-compose.yml`: Mounts both GraphQLBundle (`/var/www/html`) and GraphQL-php (`/var/www/GraphQL-php`)
- `composer.json`: Uses a path repository pointing to `/var/www/GraphQL-php`
- Version constraint: `@dev` to accept development versions from the path repository

When the GraphQL-php library is updated, the changes will be automatically reflected in the bundle's tests. To revert to the remote version, update the repository and version constraint in `composer.json`.

## Project Overview

**Type**: Symfony Bundle (PHP 8.4+)
**Purpose**: GraphQL Server integration for Symfony Framework
**Namespace**: `Youshido\GraphQLBundle`
**Dependencies**: Symfony 7.4, 99designs/graphql, PHPUnit 9.6

## Code Style Guidelines

### Formatting & Structure
- **Language**: PHP 8.4 with strict typing
- **Indentation**: 4 spaces (PSR-12)
- **Line Length**: No hard limit, but keep reasonable (~100-120 chars)
- **File Header**: Include author and date block comment (see examples below)

### Type Declarations
- **Always use strict_types**: Add `declare(strict_types=1);` after PHP opening tag
- **Type hints**: Use for all parameters and return types (no mixed/null without union)
- **Union types**: Use `|` syntax (e.g., `array|bool|string|int`)
- **Return types**: Always specify, use `void` if no return value

Example:
```php
<?php
declare(strict_types=1);

namespace Youshido\GraphQLBundle\Controller;

public function getParam(string $name): array|bool|string|int|float|\UnitEnum|null
```

### Naming Conventions
- **Classes**: PascalCase (e.g., `GraphQLController`, `AbstractContainerAwareField`)
- **Methods**: camelCase, prefixed with verb when appropriate (e.g., `executeQuery`, `getPayload`, `initializeSchemaService`)
- **Properties**: camelCase with visibility keyword (e.g., `protected ParameterBagInterface $params`)
- **Constants**: UPPER_SNAKE_CASE
- **File names**: Match class name (e.g., `GraphQLController.php` for class `GraphQLController`)

### Import Statements
- **Order**: Group imports logically
1. Symfony core components first
2. Bundle-specific components
3. External vendor packages
- **Use statements**: One per line, alphabetically sorted within groups
- **Never use**: Wildcard imports (`use Foo\*;`)

Example:
```php
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Youshido\GraphQLBundle\Exception\UnableToInitializeSchemaServiceException;
use Youshido\GraphQLBundle\Execution\Processor;
```

### Error Handling
- **Exceptions**: Create specific exception classes extending `\Exception`
- **Exception placement**: Store in `Exception/` directory with descriptive names
- **Catch specific exceptions**: Use match expressions for known exception types
- **Document throws**: Use `@throws` in PHPDoc for all exceptions

Example:
```php
try {
$this->initializeSchemaService();
} catch (UnableToInitializeSchemaServiceException) {
return new JsonResponse([['message' => 'Schema class does not exist']]);
}
```

### Classes & Methods
- **Visibility**: Always explicit (`public`, `protected`, `private`)
- **Constructor promotion**: Use PHP 8.0+ constructor property promotion
- **Traits**: Use for shared functionality (e.g., `ContainerAwareTrait`)
- **Inheritance**: Extend base classes when appropriate
- **PHPDoc blocks**: Include for complex methods with `@param`, `@return`, `@throws`

Example:
```php
class GraphQLController extends AbstractController
{
public function __construct(protected ParameterBagInterface $params)
{
}

protected function executeQuery($query, $variables): array
{
/** @var Processor $processor */
$processor = $this->container->get('graphql.processor');
$processor->processPayload($query, $variables);
return $processor->getResponseData();
}
}
```

### String Operations
- **Use modern syntax**: `str_starts_with()`, `str_ends_with()` (PHP 8.0+)
- **Ternary shorthand**: Use `??` for null coalescing
- **Arrow functions**: Use for simple callbacks in array_map, array_filter, etc.

Example:
```php
$queryResponses = array_map(fn($queryData) => $this->executeQuery($queryData['query'], $queryData['variables']), $queries);
$variables = is_string($variables) ? json_decode($variables, true) : $variables;
```

### Testing
- **Framework**: PHPUnit 9.6
- **Base class**: Extend `PHPUnit\Framework\TestCase`
- **Naming**: `*Test` suffix (e.g., `GraphQLExtensionTest`)
- **Methods**: `test*` prefix (e.g., `testDefaultConfigIsUsed()`)
- **Assertions**: Use modern assertions (`assertEquals`, `assertTrue`, `assertNull`)
- **Setup/Teardown**: Use `setUp()` and `tearDown()` methods when needed

## Directory Structure
```
Command/ - CLI commands
Config/ - Configuration and rules
Controller/ - HTTP controllers
DependencyInjection/ - DI extension and configuration
Event/ - Event classes and subscribers
Exception/ - Custom exception classes
Execution/ - Query execution logic
Field/ - GraphQL field definitions
Resources/ - Templates, configs, assets
Security/ - Security voters and managers
Tests/ - PHPUnit test suites
```

## Git Workflow
- Use descriptive commit messages
- Run tests before committing: `./vendor/bin/phpunit`
- Use Rector for code modernization: `./vendor/bin/rector process --dry-run` first
- Keep commits atomic and focused
60 changes: 60 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **Security**: Fixed batch query state mutation in PayloadParser where variables from one query could leak to subsequent queries in batch requests (Issue #2)
- **Security**: Fixed HTTP 500 error responses returning 200 status code and exposing internal schema class names in error messages (Issue #20)
- **Security**: Added descriptive context to AccessDeniedException messages including field and operation names for better debugging (Issues #4, #19)

### Added

- **Security**: Added configurable maximum payload size limit (10MB default) to prevent denial-of-service attacks via large JSON payloads (Issue #18)
- **Security**: Enhanced default CORS headers with support for Authorization header and proper Access-Control-Max-Age (Issue #18)
- **Code Quality**: Created Constants class (Config/Constants.php) to centralize all magic strings for improved maintainability (Smell #2)
- **Testing**: Added comprehensive integration tests for GraphQLController covering single queries, batch queries, different content types, error handling, and CORS support (Issue #17)

### Changed

- **Code Quality**: Consolidated duplicate variable parsing methods in PayloadParser into single `parseVariables()` method (Smell #1)
- **Code Quality**: Updated GraphQLController and PayloadParser to use Constants class for all service and parameter names
- **Refactoring**: Removed Symfony 4.2 compatibility code (KernelVersionHelper class) since bundle now requires Symfony 7.4+ (Issue #3)
- **Refactoring**: Fixed parameter name typo in Processor::setSecurityManager() - `$securityManger` → `$securityManager` (Issue #16)

### Improved

- **Documentation**: Added inline documentation for new security features and payload validation
- **Documentation**: Enhanced PHPDoc blocks for PayloadParser and GraphQLController methods with parameter and return type details

### Removed

- Removed KernelVersionHelper class (was checking Symfony 4.2 compatibility, no longer needed)
- Removed KernelVersionHelperTest test case (associated with removed helper)

---

## [1.0.0] - 2024-XX-XX (Previous Release)

### Added

- Initial release of GraphQL Bundle for Symfony 7.4+
- GraphQL request processing with query and batch query support
- Security voters for field and operation-level access control
- Request payload parsing with support for multiple content types
- Response header customization via configuration
- Event dispatching for GraphQL query resolution

### Features

- Full GraphQL server integration with Symfony Framework
- Support for single and batch queries
- Security manager for access control
- Customizable field and operation authorization
- Event-driven architecture for query resolution
- Logging support for GraphQL queries
53 changes: 25 additions & 28 deletions Command/GraphQLConfigureCommand.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);

namespace Youshido\GraphQLBundle\Command;

Expand All @@ -13,22 +14,17 @@

class GraphQLConfigureCommand extends Command
{
const PROJECT_NAMESPACE = 'App';
public const PROJECT_NAMESPACE = 'App';

/** @var Container */
protected $container;

public function __construct(ContainerInterface $container)
public function __construct(protected Container $container)
{
$this->container = $container;

parent::__construct();
}

/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setName('graphql:configure')
Expand All @@ -39,12 +35,13 @@ protected function configure()
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$isComposerCall = $input->getOption('composer');

$rootDir = $this->container->getParameter('kernel.root_dir');
$configFile = $rootDir . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'config/packages/graphql.yml';
$projectDir = $this->container->getParameter('kernel.project_dir');
$rootDir = $projectDir . DIRECTORY_SEPARATOR . 'src';
$configFile = $projectDir . DIRECTORY_SEPARATOR . 'config/packages/graphql.yml';

$className = 'Schema';
$schemaNamespace = self::PROJECT_NAMESPACE . '\\GraphQL';
Expand All @@ -59,11 +56,13 @@ protected function execute(InputInterface $input, OutputInterface $output)
} else {
$question = new ConfirmationQuestion(sprintf('Confirm creating class at %s ? [Y/n]', $schemaNamespace . '\\' . $className), true);
if (!$inputHelper->ask($input, $output, $question)) {
return;
return Command::SUCCESS;
}

if (!is_dir($graphqlPath)) {
mkdir($graphqlPath, 0777, true);
if (!mkdir($graphqlPath, 0755, true) && !is_dir($graphqlPath)) {
throw new \RuntimeException(sprintf('Directory "%s" was not created', $graphqlPath));
}
}
file_put_contents($classPath, $this->getSchemaClassTemplate($schemaNamespace, $className));

Expand All @@ -73,14 +72,17 @@ protected function execute(InputInterface $input, OutputInterface $output)
if (!file_exists($configFile)) {
$question = new ConfirmationQuestion(sprintf('Config file not found (look at %s). Create it? [Y/n]', $configFile), true);
if (!$inputHelper->ask($input, $output, $question)) {
return;
return Command::SUCCESS;
}

touch($configFile);
}

$originalConfigData = file_get_contents($configFile);
if (strpos($originalConfigData, 'graphql') === false) {
$originalConfigData = @file_get_contents($configFile);
if ($originalConfigData === false) {
throw new \RuntimeException(sprintf('Unable to read configuration file "%s"', $configFile));
}
if (!str_contains($originalConfigData, 'graphql')) {
$projectNameSpace = self::PROJECT_NAMESPACE;
$configData = <<<CONFIG
graphql:
Expand All @@ -107,19 +109,19 @@ protected function execute(InputInterface $input, OutputInterface $output)
$output->writeln('GraphQL default route was found.');
}
}

return Command::SUCCESS;
}

/**
* @return null|string
*
* @throws \Exception
*/
protected function getMainRouteConfig()
protected function getMainRouteConfig(): string|null
{
$routerResources = $this->container->get('router')->getRouteCollection()->getResources();
foreach ($routerResources as $resource) {
/** @var FileResource|DirectoryResource $resource */
if (method_exists($resource, 'getResource') && substr($resource->getResource(), -11) == 'routes.yaml') {
if (method_exists($resource, 'getResource') && str_ends_with($resource->getResource(), 'routes.yaml')) {
return $resource->getResource();
}
}
Expand All @@ -128,27 +130,22 @@ protected function getMainRouteConfig()
}

/**
* @return bool
* @throws \Exception
*/
protected function graphQLRouteExists()
protected function graphQLRouteExists(): bool
{
$routerResources = $this->container->get('router')->getRouteCollection()->getResources();
foreach ($routerResources as $resource) {
/** @var FileResource|DirectoryResource $resource */
if (method_exists($resource, 'getResource') && strpos($resource->getResource(), 'GraphQLController.php') !== false) {
if (method_exists($resource, 'getResource') && str_contains($resource->getResource(), 'GraphQLController.php')) {
return true;
}
}

return false;
}

protected function generateRoutes()
{
}

protected function getSchemaClassTemplate($nameSpace, $className = 'Schema')
protected function getSchemaClassTemplate(string $nameSpace, string $className = 'Schema'): string
{
$tpl = <<<TEXT
<?php
Expand Down
Loading