diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 67a9e35cff..17fd73bbc0 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -56,6 +56,9 @@ jobs: - name: "Remove conflicting dependencies that are not needed here" run: composer remove --dev --no-update phpbench/phpbench rector/rector + - if: matrix.laravel-version != '^10' + run: composer remove --dev --no-update laravel/pennant + - run: > composer require illuminate/contracts:${{ matrix.laravel-version }} @@ -124,6 +127,9 @@ jobs: - name: "Remove conflicting dependencies that are not needed here" run: composer remove --dev --no-update nunomaduro/larastan phpstan/phpstan-mockery phpbench/phpbench rector/rector + - if: matrix.laravel-version != '^10' + run: composer remove --dev --no-update laravel/pennant + - run: > composer require illuminate/contracts:${{ matrix.laravel-version }} @@ -141,9 +147,9 @@ jobs: strategy: matrix: php-version: - - 8.2 + - "8.2" laravel-version: - - ^9 + - "^10" services: mysql: @@ -190,9 +196,9 @@ jobs: strategy: matrix: php-version: - - 8.2 + - "8.2" laravel-version: - - ^9 + - "^10" steps: - uses: actions/checkout@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c12a99930..7cd8b62690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Add directive `@feature` to conditionally add annotated elements based on the state of a feature using [Laravel Pennant](https://github.com/laravel/pennant) https://github.com/nuwave/lighthouse/pull/2442 + ## v6.17.0 ### Added diff --git a/benchmarks/QueryBench.php b/benchmarks/QueryBench.php index f127bfe14e..d193e529af 100644 --- a/benchmarks/QueryBench.php +++ b/benchmarks/QueryBench.php @@ -13,6 +13,11 @@ abstract class QueryBench extends TestCase /** Cached graphQL endpoint. */ protected string $graphQLEndpoint; + public function __construct() + { + parent::__construct(static::class); + } + public function setUp(): void { parent::setUp(); diff --git a/composer.json b/composer.json index 3f0d6ce2c3..06673034a7 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ "laravel/framework": "^9 || ^10", "laravel/legacy-factories": "^1.1.1", "laravel/lumen-framework": "^9 || ^10 || dev-master", + "laravel/pennant": "^1", "laravel/scout": "^8 || ^9 || ^10", "mattiasgeniar/phpunit-query-count-assertions": "^1.1", "mll-lab/graphql-php-scalars": "^6", @@ -72,6 +73,7 @@ }, "suggest": { "bensampo/laravel-enum": "Convenient enum definitions that can easily be registered in your Schema", + "laravel/pennant": "Required for the @feature directive", "laravel/scout": "Required for the @search directive", "mll-lab/graphql-php-scalars": "Useful scalar types, required for @whereConditions", "mll-lab/laravel-graphiql": "A graphical interactive in-browser GraphQL IDE - integrated with Laravel", diff --git a/docs/master/api-reference/directives.md b/docs/master/api-reference/directives.md index 7b78ba5f44..9edd0db051 100644 --- a/docs/master/api-reference/directives.md +++ b/docs/master/api-reference/directives.md @@ -1074,6 +1074,49 @@ type Mutation { } ``` +## @feature + +```graphql +""" +Include the annotated element in the schema depending on a Laravel Pennant feature. +""" +directive @feature( + """ + The name of the feature to be checked (can be a string or class name). + """ + name: String! + + """ + Specify what the state of the feature should be for the field to be included. + """ + when: FeatureState! = ACTIVE +) on FIELD_DEFINITION | OBJECT + +""" +Options for the `when` argument of `@feature`. +""" +enum FeatureState { + """ + Indicates an active feature. + """ + ACTIVE + + """ + Indicates an inactive feature. + """ + INACTIVE +} +``` + +Requires the installation of [Laravel Pennant](https://laravel.com/docs/pennant) +and manual registration of the service provider in `config/app.php`: + +```php +'providers' => [ + \Nuwave\Lighthouse\Pennant\PennantServiceProvider::class, +], +``` + ## @field ```graphql diff --git a/docs/master/digging-deeper/feature-toggles.md b/docs/master/digging-deeper/feature-toggles.md index 5e25030b66..5bcbd07ddd 100644 --- a/docs/master/digging-deeper/feature-toggles.md +++ b/docs/master/digging-deeper/feature-toggles.md @@ -25,9 +25,46 @@ type Query { } ``` +## @feature + +The [@feature](../api-reference/directives.md#feature) directive allows to include fields in the schema depending +on a [Laravel Pennant](https://laravel.com/docs/pennant) feature. + +For example, you might want a new experimental field only to be available when the according feature is active: + +```graphql +type Query { + experimentalField: String! @feature(name: "new-api") +} +``` + +In this case, `experimentalField` will only be included when the `new-api` feature is active. + +Another example would be to only include a field when the feature is inactive: + +```graphql +type Query { + deprecatedField: String! @feature(name: "new-api", when: "INACTIVE") +} +``` + +When using [class based features](https://laravel.com/docs/pennant#class-based-features), +the fully qualified class name must be used as the value for the `name` argument: + +```graphql +type Query { + experimentalField: String! @feature(name: "App\\Features\\NewApi") +} +``` + ## Interaction With Schema Cache [@show](../api-reference/directives.md#show) and [@hide](../api-reference/directives.md#hide) work by manipulating the schema. This means that when using their `env` option, the inclusion or exclusion of elements depends on the value of `app()->environment()` at the time the schema is built and not update on later environment changes. If you are pre-generating your schema cache, make sure to match the environment to your deployment target. + +The same goes for [@feature](../api-reference/directives.md#feature). Whether a field is included in the schema will be +based on the state of a feature at the time the schema is built. In addition, if you are pre-generating your schema cache, +you will only be able to use features that support [nullable scopes](https://laravel.com/docs/pennant#nullable-scope), +as there won't be an authenticated user to check the feature against. diff --git a/phpstan.neon b/phpstan.neon index f292dec627..788c37404a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -15,6 +15,9 @@ parameters: excludePaths: - tests/Utils/Models/WithoutRelationClassImport.php # Intentionally wrong - tests/LaravelPhpdocAlignmentFixer.php # Copied from laravel/pint + # laravel/pennant requires Laravel 10 + - src/Pennant + - tests/Integration/Pennant ignoreErrors: # PHPStan does not get it - '#Parameter \#1 \$callback of static method Closure::fromCallable\(\) expects callable\(\): mixed, array{object, .*} given\.#' @@ -24,6 +27,7 @@ parameters: - path: tests/database/factories/* message: '#Variable \$factory might not be defined#' + # Mixins are magical - path: src/Testing/TestResponseMixin.php message: '#Method Nuwave\\Lighthouse\\Testing\\TestResponseMixin::assertGraphQLErrorMessage\(\) invoked with 1 parameter, 0 required\.#' diff --git a/src/Pennant/FeatureDirective.php b/src/Pennant/FeatureDirective.php new file mode 100644 index 0000000000..d1f37eaf89 --- /dev/null +++ b/src/Pennant/FeatureDirective.php @@ -0,0 +1,63 @@ +directiveArgValue('name'); + $requiredFeatureState = $this->directiveArgValue('when', 'ACTIVE'); + + return match ($requiredFeatureState) { + 'ACTIVE' => $this->features->inactive($feature), + 'INACTIVE' => $this->features->active($feature), + default => throw new DefinitionException("Expected FeatureState `ACTIVE` or `INACTIVE` for argument `when` of @{$this->name()} on {$this->nodeName()}, got `{$requiredFeatureState}`."), + }; + } +} diff --git a/src/Pennant/PennantServiceProvider.php b/src/Pennant/PennantServiceProvider.php new file mode 100644 index 0000000000..5fda3b763b --- /dev/null +++ b/src/Pennant/PennantServiceProvider.php @@ -0,0 +1,15 @@ +listen(RegisterDirectiveNamespaces::class, static fn (): string => __NAMESPACE__); + } +} diff --git a/tests/Integration/Pennant/FeatureDirectiveTest.php b/tests/Integration/Pennant/FeatureDirectiveTest.php new file mode 100644 index 0000000000..05f767c610 --- /dev/null +++ b/tests/Integration/Pennant/FeatureDirectiveTest.php @@ -0,0 +1,191 @@ +markTestSkipped('Requires laravel/pennant, which requires Laravel 10'); + } + } + + protected function getPackageProviders($app): array + { + if (AppVersion::below(10)) { + return parent::getPackageProviders($app); + } + + return array_merge( + parent::getPackageProviders($app), + [ + LaravelPennantServiceProvider::class, + LighthousePennantServiceProvider::class, + ], + ); + } + + public function testUnavailableWhenFeatureIsInactive(): void + { + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type Query { + fieldWhenActive: String! + @feature(name: "new-api", when: ACTIVE) + @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + { + fieldWhenActive + } + GRAPHQL, + ); + + $this->assertCannotQueryFieldErrorMessage($response, 'fieldWhenActive'); + } + + public function testUnavailableWhenFeatureIsInactiveWithDefaultFeatureState(): void + { + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type Query { + fieldWhenActive: String! + @feature(name: "new-api") + @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + { + fieldWhenActive + } + GRAPHQL, + ); + + $this->assertCannotQueryFieldErrorMessage($response, 'fieldWhenActive'); + } + + public function testUnavailableWhenFeatureIsActive(): void + { + Feature::define('new-api', fn (): bool => true); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type Query { + fieldWhenInactive: String! + @feature(name: "new-api", when: INACTIVE) + @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + { + fieldWhenInactive + } + GRAPHQL, + ); + + $this->assertCannotQueryFieldErrorMessage($response, 'fieldWhenInactive'); + } + + public function testAvailableWhenFeatureIsActive(): void + { + Feature::define('new-api', fn (): bool => true); + $fieldValue = 'active'; + $this->mockResolver(fn (): string => $fieldValue); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type Query { + fieldWhenActive: String! + @feature(name: "new-api", when: ACTIVE) + @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + { + fieldWhenActive + } + GRAPHQL, + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'fieldWhenActive' => $fieldValue, + ], + ]); + } + + public function testAvailableWhenFeatureIsActiveWithDefaultFeatureState(): void + { + Feature::define('new-api', fn (): bool => true); + $fieldValue = 'active'; + $this->mockResolver(fn (): string => $fieldValue); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type Query { + fieldWhenActive: String! + @feature(name: "new-api") + @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + { + fieldWhenActive + } + GRAPHQL, + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'fieldWhenActive' => $fieldValue, + ], + ]); + } + + public function testAvailableWhenFeatureIsInactive(): void + { + $fieldValue = 'inactive'; + $this->mockResolver(fn (): string => $fieldValue); + $this->schema = /* @lang GraphQL */ <<<'GRAPHQL' + type Query { + fieldWhenInactive: String! + @feature(name: "new-api", when: INACTIVE) + @mock + } + GRAPHQL; + + $response = $this->graphQL(/* @lang GraphQL */ <<<'GRAPHQL' + { + fieldWhenInactive + } + GRAPHQL, + ); + + $response->assertGraphQLErrorFree(); + $response->assertJson([ + 'data' => [ + 'fieldWhenInactive' => $fieldValue, + ], + ]); + } + + private function assertCannotQueryFieldErrorMessage(TestResponse $response, string $expected): void + { + $response->assertGraphQLErrorMessage("Cannot query field \"{$expected}\" on type \"Query\"."); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index f846c7ebb2..a643483a2c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -18,6 +18,7 @@ use Nuwave\Lighthouse\LighthouseServiceProvider; use Nuwave\Lighthouse\OrderBy\OrderByServiceProvider; use Nuwave\Lighthouse\Pagination\PaginationServiceProvider; +use Nuwave\Lighthouse\Pennant\PennantServiceProvider as LighthousePennantServiceProvider; use Nuwave\Lighthouse\Schema\SchemaBuilder; use Nuwave\Lighthouse\Scout\ScoutServiceProvider as LighthouseScoutServiceProvider; use Nuwave\Lighthouse\SoftDeletes\SoftDeletesServiceProvider; @@ -169,6 +170,8 @@ protected function getEnvironmentSetUp($app): void 'prefix' => 'lighthouse-test-', ]); + $config->set('pennant.default', 'array'); + // Defaults to "algolia", which is not needed in our test setup $config->set('scout.driver', null);