├── .gitignore ├── .github ├── dependabot.yml └── workflows │ └── continuous_integration.yml ├── src ├── routes │ └── routes.php ├── Listeners │ └── CachePurger.php ├── Mappers │ ├── PaginatorTypeMapperFactory.php │ ├── Parameters │ │ ├── InvalidValidateAnnotationException.php │ │ ├── ParameterValidator.php │ │ └── ValidateFieldMiddleware.php │ ├── PaginatorMissingParameterException.php │ └── PaginatorTypeMapper.php ├── Utils │ └── SchemaPrinter.php ├── Exceptions │ └── ValidateException.php ├── Security │ ├── AuthorizationService.php │ └── AuthenticationService.php ├── Console │ └── Commands │ │ └── GraphqliteExportSchema.php ├── Annotations │ └── Validate.php ├── SanePsr11ContainerAdapter.php ├── Controllers │ └── GraphQLiteController.php └── Providers │ └── GraphQLiteServiceProvider.php ├── README.md ├── tests ├── Console │ └── Commands │ │ └── GraphqliteExportSchemaTest.php ├── Fixtures │ └── App │ │ └── Http │ │ └── Controllers │ │ └── TestController.php └── Providers │ └── GraphQLiteServiceProviderTest.php ├── phpunit.xml.dist ├── config └── graphqlite.php └── composer.json /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /vendor 3 | /build 4 | /.phpunit.result.cache 5 | tags 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /src/routes/routes.php: -------------------------------------------------------------------------------- 1 | config('graphqlite.middleware', ['web'])], function () { 4 | Route::get(config('graphqlite.uri', '/graphql'), 'TheCodingMachine\\GraphQLite\\Laravel\\Controllers\\GraphQLiteController@index'); 5 | Route::post(config('graphqlite.uri', '/graphql'), 'TheCodingMachine\\GraphQLite\\Laravel\\Controllers\\GraphQLiteController@index'); 6 | }); -------------------------------------------------------------------------------- /src/Listeners/CachePurger.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 19 | } 20 | 21 | /** 22 | * Handle the event. 23 | * 24 | * @return void 25 | */ 26 | public function handle() 27 | { 28 | $this->cache->clear(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Mappers/PaginatorTypeMapperFactory.php: -------------------------------------------------------------------------------- 1 | getRecursiveTypeMapper()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Utils/SchemaPrinter.php: -------------------------------------------------------------------------------- 1 | getQueryType() && $schema->getQueryType() === $schema->getType('Query') 16 | && $schema->getMutationType() && $schema->getMutationType() === $schema->getType('Mutation'); 17 | } 18 | } -------------------------------------------------------------------------------- /src/Mappers/Parameters/InvalidValidateAnnotationException.php: -------------------------------------------------------------------------------- 1 | getDeclaringClass(); 14 | $method = $refParameter->getDeclaringFunction(); 15 | return new self('In method '.$class.'::'.$method.', the @Validate annotation is targeting parameter $'.$refParameter->getName().'. You cannot target this parameter because it is not part of the GraphQL Input type. You can only validate parameters coming from the end user.'); 16 | } 17 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Stable Version](https://poser.pugx.org/thecodingmachine/graphqlite-laravel/v/stable)](https://packagist.org/packages/thecodingmachine/graphqlite-laravel) 2 | [![Latest Unstable Version](https://poser.pugx.org/thecodingmachine/graphqlite-laravel/v/unstable)](https://packagist.org/packages/thecodingmachine/graphqlite-laravel) 3 | [![License](https://poser.pugx.org/thecodingmachine/graphqlite-laravel/license)](https://packagist.org/packages/thecodingmachine/graphqlite-laravel) 4 | [![Build Status](https://github.com/thecodingmachine/graphqlite/workflows/Continuous%20Integration/badge.svg)](https://github.com/thecodingmachine/graphqlite/actions) 5 | 6 | 7 | Laravel GraphQLite bindings 8 | =========================== 9 | 10 | GraphQLite integration package for Laravel. 11 | 12 | See [documentation](https://graphqlite.thecodingmachine.io/docs/laravel-package.html) 13 | -------------------------------------------------------------------------------- /tests/Console/Commands/GraphqliteExportSchemaTest.php: -------------------------------------------------------------------------------- 1 | artisan('graphqlite:export-schema -O test.graphql') 21 | ->assertExitCode(0); 22 | 23 | $this->assertFileExists('test.graphql'); 24 | $content = file_get_contents('test.graphql'); 25 | $this->assertStringContainsString('type Query {', $content); 26 | unlink('test.graphql'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src/ 6 | 7 | 8 | src/routes 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ./tests/ 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Exceptions/ValidateException.php: -------------------------------------------------------------------------------- 1 | argumentName = $argumentName; 19 | return $exception; 20 | } 21 | 22 | public function isClientSafe(): bool 23 | { 24 | return true; 25 | } 26 | 27 | 28 | /** 29 | * Returns the "extensions" object attached to the GraphQL error. 30 | * 31 | * @return array 32 | */ 33 | public function getExtensions(): array 34 | { 35 | return [ 36 | 'argument' => $this->argumentName 37 | ]; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /config/graphqlite.php: -------------------------------------------------------------------------------- 1 | 'App\\Http\\Controllers', 21 | 'types' => 'App\\', 22 | 'debug' => DebugFlag::RETHROW_UNSAFE_EXCEPTIONS, 23 | 'uri' => env('GRAPHQLITE_URI', '/graphql'), 24 | 'middleware' => ['web'], 25 | 26 | // Sets the status code in the HTTP request where operations have errors. 27 | 'http_code_decider' => HttpCodeDecider::class, 28 | ]; 29 | -------------------------------------------------------------------------------- /src/Security/AuthorizationService.php: -------------------------------------------------------------------------------- 1 | gate = $gate; 24 | $this->authenticationService = $authenticationService; 25 | } 26 | 27 | /** 28 | * Returns true if the "current" user has access to the right "$right" 29 | * 30 | * @param mixed $subject The scope this right applies on. $subject is typically an object or a FQCN. Set $subject to "null" if the right is global. 31 | */ 32 | public function isAllowed(string $right, $subject = null): bool 33 | { 34 | return $this->gate->forUser($this->authenticationService->getUser())->check($right, $subject); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Fixtures/App/Http/Controllers/TestController.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 27 | $this->guards = $guards; 28 | } 29 | 30 | /** 31 | * Returns true if the "current" user is logged 32 | */ 33 | public function isLogged(): bool 34 | { 35 | foreach ($this->guards as $guard) { 36 | if ($this->auth->guard($guard)->check()) { 37 | return true; 38 | } 39 | } 40 | return false; 41 | } 42 | 43 | /** 44 | * Returns an object representing the current logged user. 45 | * Can return null if the user is not logged. 46 | */ 47 | public function getUser(): ?object 48 | { 49 | foreach ($this->guards as $guard) { 50 | $user = $this->auth->guard($guard)->user(); 51 | if ($user !== null) { 52 | return $user; 53 | } 54 | } 55 | return null; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Console/Commands/GraphqliteExportSchema.php: -------------------------------------------------------------------------------- 1 | option('output'); 41 | 42 | $sdl = SchemaPrinter::doPrint($this->schema, [ 43 | "sortArguments" => true, 44 | "sortEnumValues" => true, 45 | "sortFields" => true, 46 | "sortInputFields" => true, 47 | "sortTypes" => true, 48 | ]); 49 | 50 | if ($output === null) { 51 | $this->line($sdl); 52 | } else { 53 | file_put_contents($output, $sdl); 54 | } 55 | 56 | return Command::SUCCESS; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/continuous_integration.yml: -------------------------------------------------------------------------------- 1 | name: "Continuous Integration" 2 | 3 | on: 4 | pull_request: ~ 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | 11 | continuous-integration: 12 | name: "Continuous Integration" 13 | 14 | runs-on: "ubuntu-latest" 15 | 16 | strategy: 17 | matrix: 18 | include: 19 | - install-args: "--prefer-lowest" 20 | php-version: "8.1" 21 | - install-args: "" 22 | php-version: "8.1" 23 | - install-args: "" 24 | php-version: "8.2" 25 | fail-fast: false 26 | 27 | steps: 28 | # Cancel previous runs of the same branch 29 | - name: cancel 30 | uses: styfle/cancel-workflow-action@0.10.0 31 | with: 32 | access_token: ${{ github.token }} 33 | 34 | - name: "Checkout" 35 | uses: "actions/checkout@v3" 36 | 37 | - name: "Install PHP with extensions" 38 | uses: "shivammathur/setup-php@v2" 39 | with: 40 | coverage: "xdebug" 41 | php-version: "${{ matrix.php-version }}" 42 | tools: composer:v2 43 | 44 | - name: composer-cache-dir 45 | id: composercache 46 | run: | 47 | echo "::set-output name=dir::$(composer config cache-files-dir)" 48 | - name: composer-cache 49 | uses: actions/cache@v3 50 | with: 51 | path: ${{ steps.composercache.outputs.dir }} 52 | key: composer-${{ hashFiles('**/composer.json') }}-${{ matrix.install-args }} 53 | restore-keys: | 54 | composer-${{ hashFiles('**/composer.json') }}-${{ matrix.install-args }} 55 | composer-${{ hashFiles('**/composer.json') }}- 56 | composer- 57 | - name: "Install dependencies with composer" 58 | run: | 59 | composer update ${{ matrix.install-args }} --no-interaction --no-progress --prefer-dist 60 | - name: "Run tests with phpunit/phpunit" 61 | run: "vendor/bin/phpunit" -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thecodingmachine/graphqlite-laravel", 3 | "description": "A Laravel service provider package to help you get started with GraphQLite in Laravel.", 4 | "keywords": [ 5 | "GraphQL", 6 | "GraphQLite", 7 | "Laravel" 8 | ], 9 | "homepage": "https://github.com/thecodingmachine/graphqlite", 10 | "type": "library", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "David N\u00e9grier", 15 | "email": "d.negrier@thecodingmachine.com", 16 | "homepage": "https://workadventu.re" 17 | } 18 | ], 19 | "scripts": { 20 | "test": "phpunit" 21 | }, 22 | "require": { 23 | "php": "^8.1", 24 | "thecodingmachine/graphqlite": "^v6.2.1", 25 | "illuminate/console": "^9 || ^10 | ^11", 26 | "illuminate/container": "^9 || ^10 | ^11", 27 | "illuminate/support": "^9 || ^10 | ^11", 28 | "illuminate/cache": "^9 || ^10 | ^11", 29 | "symfony/psr-http-message-bridge": "^1.3.0 || ^2 || ^6", 30 | "laminas/laminas-diactoros": "^2.2.2", 31 | "symfony/cache": "^4.3 || ^5 || ^6", 32 | "psr/container": "^2.0.2" 33 | }, 34 | "require-dev": { 35 | "orchestra/testbench": "^7 || ^8", 36 | "phpunit/phpunit": "^9.6.6 || ^10.0.19", 37 | "ext-sqlite3": "*" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "TheCodingMachine\\GraphQLite\\Laravel\\": "src/" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "App\\": "tests/Fixtures/App" 47 | } 48 | }, 49 | "extra": { 50 | "branch-alias": { 51 | "dev-master": "6.1.x-dev" 52 | }, 53 | "laravel": { 54 | "providers": [ 55 | "TheCodingMachine\\GraphQLite\\Laravel\\Providers\\GraphQLiteServiceProvider" 56 | ] 57 | } 58 | }, 59 | "minimum-stability": "dev", 60 | "prefer-stable": true 61 | } 62 | -------------------------------------------------------------------------------- /src/Annotations/Validate.php: -------------------------------------------------------------------------------- 1 | $values 34 | */ 35 | public function __construct($rule = []) 36 | { 37 | $values = $rule; 38 | if (is_string($values)) { 39 | $this->rule = $values; 40 | } else { 41 | $this->rule = $values['rule'] ?? null; 42 | if (isset($values['for'])) { 43 | $this->for = ltrim($values['for'], '$'); 44 | } 45 | } 46 | if (! isset($values['rule'])) { 47 | throw new BadMethodCallException('The @Validate annotation must be passed a rule. For instance: "#Validate("email")" in PHP 8+ or "@Validate(for="$email", rule="email")" in PHP 7+'); 48 | } 49 | } 50 | 51 | public function getTarget(): string 52 | { 53 | if ($this->for === null) { 54 | throw new BadMethodCallException('The @Validate annotation must be passed a target. For instance: "@Validate(for="$email", rule="email")"'); 55 | } 56 | return $this->for; 57 | } 58 | 59 | public function getRule(): string 60 | { 61 | return $this->rule; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/SanePsr11ContainerAdapter.php: -------------------------------------------------------------------------------- 1 | container = $container; 28 | } 29 | 30 | /** 31 | * Finds an entry of the container by its identifier and returns it. 32 | * 33 | * @param string $id Identifier of the entry to look for. 34 | * 35 | * @throws NotFoundExceptionInterface No entry was found for **this** identifier. 36 | * @throws ContainerExceptionInterface Error while retrieving the entry. 37 | * 38 | * @return mixed Entry. 39 | */ 40 | public function get($id) 41 | { 42 | return $this->container->get($id); 43 | } 44 | 45 | /** 46 | * Returns true if the container can return an entry for the given identifier. 47 | * Returns false otherwise. 48 | * 49 | * `has($id)` returning true does not mean that `get($id)` will not throw an exception. 50 | * It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`. 51 | * 52 | * @param string $id Identifier of the entry to look for. 53 | * 54 | * @return bool 55 | */ 56 | public function has($id): bool 57 | { 58 | if (class_exists($id) && !$this->container->has($id)) { 59 | try { 60 | $this->container->get($id); 61 | } catch (EntryNotFoundException $e) { 62 | return false; 63 | } 64 | return true; 65 | } 66 | return $this->container->has($id); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Mappers/Parameters/ParameterValidator.php: -------------------------------------------------------------------------------- 1 | parameter = $parameter; 41 | $this->rules = $rules; 42 | $this->validationFactory = $validationFactory; 43 | $this->parameterName = $parameterName; 44 | } 45 | 46 | 47 | /** 48 | * @param array $args 49 | */ 50 | public function resolve(?object $source, array $args, mixed $context, ResolveInfo $info): mixed 51 | { 52 | $value = $this->parameter->resolve($source, $args, $context, $info); 53 | 54 | $validator = $this->validationFactory->make([$this->parameterName => $value], [$this->parameterName => $this->rules]); 55 | 56 | if ($validator->fails()) { 57 | $errorMessages = []; 58 | foreach ($validator->errors()->toArray() as $field => $errors) { 59 | foreach ($errors as $error) { 60 | $errorMessages[] = ValidateException::create($error, $field); 61 | } 62 | } 63 | GraphQLAggregateException::throwExceptions($errorMessages); 64 | } 65 | 66 | return $value; 67 | } 68 | 69 | public function getType(): InputType&Type 70 | { 71 | return $this->parameter->getType(); 72 | } 73 | 74 | public function hasDefaultValue(): bool 75 | { 76 | return $this->parameter->hasDefaultValue(); 77 | } 78 | 79 | public function getDefaultValue(): mixed 80 | { 81 | return $this->parameter->getDefaultValue(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Mappers/Parameters/ValidateFieldMiddleware.php: -------------------------------------------------------------------------------- 1 | validationFactory = $validationFactory; 56 | } 57 | 58 | public function mapParameter(ReflectionParameter $refParameter, DocBlock $docBlock, ?Type $paramTagType, ParameterAnnotations $parameterAnnotations, ParameterHandlerInterface $next): ParameterInterface 59 | { 60 | /** @var Validate[] $validateAnnotations */ 61 | $validateAnnotations = $parameterAnnotations->getAnnotationsByType(Validate::class); 62 | 63 | $parameter = $next->mapParameter($refParameter, $docBlock, $paramTagType, $parameterAnnotations); 64 | 65 | if (empty($validateAnnotations)) { 66 | return $parameter; 67 | } 68 | 69 | if (!$parameter instanceof InputTypeParameterInterface) { 70 | throw InvalidValidateAnnotationException::canOnlyValidateInputType($refParameter); 71 | } 72 | 73 | // Let's wrap the ParameterInterface into a ParameterValidator. 74 | $rules = array_map(static function(Validate $validateAnnotation): string { return $validateAnnotation->getRule(); }, $validateAnnotations); 75 | 76 | return new ParameterValidator($parameter, $refParameter->getName(), implode('|', $rules), $this->validationFactory); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Controllers/GraphQLiteController.php: -------------------------------------------------------------------------------- 1 | standardServer = $standardServer; 42 | $this->httpCodeDecider = $httpCodeDecider; 43 | $this->httpMessageFactory = $httpMessageFactory ?: new DiactorosFactory(); 44 | $this->debug = $debug === null ? false : $debug; 45 | } 46 | 47 | /** 48 | * Handle an incoming request. 49 | * 50 | * @param \Illuminate\Http\Request $request 51 | * @param \Closure $next 52 | * @return mixed 53 | */ 54 | public function index(Request $request): JsonResponse 55 | { 56 | $psr7Request = $this->httpMessageFactory->createRequest($request); 57 | 58 | if (strtoupper($request->getMethod()) === "POST" && empty($psr7Request->getParsedBody())) { 59 | $content = $psr7Request->getBody()->getContents(); 60 | $parsedBody = json_decode($content, true); 61 | if (json_last_error() !== JSON_ERROR_NONE) { 62 | throw new \RuntimeException('Invalid JSON received in POST body: '.json_last_error_msg()); 63 | } 64 | $psr7Request = $psr7Request->withParsedBody($parsedBody); 65 | } 66 | 67 | if (class_exists('\GraphQL\Upload\UploadMiddleware')) { 68 | // Let's parse the request and adapt it for file uploads. 69 | $uploadMiddleware = new UploadMiddleware(); 70 | $psr7Request = $uploadMiddleware->processRequest($psr7Request); 71 | } 72 | 73 | return $this->handlePsr7Request($psr7Request); 74 | } 75 | 76 | private function handlePsr7Request(ServerRequestInterface $request): JsonResponse 77 | { 78 | $result = $this->standardServer->executePsrRequest($request); 79 | 80 | $httpCodeDecider = $this->httpCodeDecider; 81 | if ($result instanceof ExecutionResult) { 82 | return new JsonResponse($result->toArray($this->debug), $httpCodeDecider->decideHttpStatusCode($result)); 83 | } 84 | if (is_array($result)) { 85 | $finalResult = array_map(function (ExecutionResult $executionResult) { 86 | return new JsonResponse($executionResult->toArray($this->debug)); 87 | }, $result); 88 | // Let's return the highest result. 89 | $statuses = array_map([$httpCodeDecider, 'decideHttpStatusCode'], $result); 90 | $status = max($statuses); 91 | return new JsonResponse($finalResult, $status); 92 | } 93 | if ($result instanceof Promise) { 94 | throw new RuntimeException('Only SyncPromiseAdapter is supported'); 95 | } 96 | throw new RuntimeException('Unexpected response from StandardServer::executePsrRequest'); // @codeCoverageIgnore 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/Providers/GraphQLiteServiceProviderTest.php: -------------------------------------------------------------------------------- 1 | app->make(Schema::class); 31 | $this->assertInstanceOf(Schema::class, $schema); 32 | } 33 | 34 | public function testHttpQuery() 35 | { 36 | $response = $this->json('POST', '/graphql', ['query' => '{ test }']); 37 | $this->assertSame(200, $response->getStatusCode(), $response->getContent()); 38 | $response->assertJson(["data" => ["test" => "foo"]]); 39 | } 40 | 41 | public function testAuthentication() 42 | { 43 | $response = $this->json('POST', '/graphql', ['query' => '{ testLogged }']); 44 | $this->assertSame(401, $response->getStatusCode(), $response->getContent()); 45 | $response->assertJson(["errors" => [["message" => "You need to be logged to access this field"]]]); 46 | } 47 | 48 | public function testPagination() 49 | { 50 | $response = $this->json('POST', '/graphql', ['query' => <<assertSame(200, $response->getStatusCode(), $response->getContent()); 69 | $response->assertJson([ 70 | "data" => [ 71 | "testPaginator" => [ 72 | "items" => [ 73 | 1, 74 | 2, 75 | 3, 76 | 4 77 | ], 78 | "firstItem" => 5, 79 | "lastItem" => 8, 80 | "hasMorePages" => true, 81 | "perPage" => 4, 82 | "hasPages" => true, 83 | "currentPage" => 2, 84 | "isEmpty" => false, 85 | "isNotEmpty" => true, 86 | "totalCount" => 42, 87 | "lastPage" => 11, 88 | ] 89 | ] 90 | ]); 91 | } 92 | 93 | public function testValidator() 94 | { 95 | $response = $this->json('POST', '/graphql', ['query' => '{ testValidator(foo:"a", bar:0) }']); 96 | $response->assertJson([ 97 | 'errors' => [ 98 | [ 99 | 'extensions' => [ 100 | 'argument' => 'foo', 101 | ], 102 | ], 103 | [ 104 | 'extensions' => [ 105 | 'argument' => 'bar', 106 | ], 107 | ] 108 | ] 109 | ]); 110 | $this->assertStringContainsString('must be a valid email address.', $response->json('errors')[0]['message']); 111 | $this->assertStringContainsString('must be greater than 42.', $response->json('errors')[1]['message']); 112 | 113 | $this->assertSame(400, $response->getStatusCode(), $response->getContent()); 114 | } 115 | 116 | public function testValidatorMultiple() 117 | { 118 | $response = $this->json('POST', '/graphql', ['query' => '{ testValidatorMultiple(foo:"191.168.1") }']); 119 | $response->assertJson([ 120 | 'errors' => [ 121 | [ 122 | 'extensions' => [ 123 | 'argument' => 'foo', 124 | ], 125 | ], 126 | [ 127 | 'extensions' => [ 128 | 'argument' => 'foo', 129 | ], 130 | ] 131 | ] 132 | ]); 133 | 134 | $this->assertStringContainsString('must start with one of the following: 192', $response->json('errors')[0]['message']); 135 | $this->assertStringContainsString('must be a valid IPv4 address.', $response->json('errors')[1]['message']); 136 | 137 | $this->assertSame(400, $response->getStatusCode(), $response->getContent()); 138 | $response = $this->json('POST', '/graphql', ['query' => '{ testValidatorMultiple(foo:"192.168.1") }']); 139 | $response->assertJson([ 140 | 'errors' => [ 141 | [ 142 | 'extensions' => [ 143 | 'argument' => 'foo', 144 | ], 145 | ] 146 | ] 147 | ]); 148 | $this->assertStringContainsString('must be a valid IPv4 address.', $response->json('errors')[0]['message']); 149 | 150 | $this->assertSame(400, $response->getStatusCode(), $response->getContent()); 151 | 152 | $this->assertSame(400, $response->getStatusCode(), $response->getContent()); 153 | $response = $this->json('POST', '/graphql', ['query' => '{ testValidatorMultiple(foo:"191.168.1.1") }']); 154 | $response->assertJson([ 155 | 'errors' => [ 156 | [ 157 | 'extensions' => [ 158 | 'argument' => 'foo', 159 | ], 160 | ] 161 | ] 162 | ]); 163 | $this->assertStringContainsString('must start with one of the following: 192', $response->json('errors')[0]['message']); 164 | 165 | $this->assertSame(400, $response->getStatusCode(), $response->getContent()); 166 | 167 | $response = $this->json('POST', '/graphql', ['query' => '{ testValidatorMultiple(foo:"192.168.1.1") }']); 168 | $this->assertSame(200, $response->getStatusCode(), $response->getContent()); 169 | } 170 | 171 | public function testCachePurger(): void 172 | { 173 | $cachePurger = $this->app->make(CachePurger::class); 174 | $cachePurger->handle(); 175 | $this->assertTrue(true); 176 | } 177 | 178 | /** 179 | * Asserts that the status code has been taken from the HttpCodeDeciderInterface. 180 | */ 181 | public function testChangeTheCodeDecider() 182 | { 183 | $this->app->instance(HttpCodeDeciderInterface::class, $this->newCodeDecider(418)); 184 | 185 | $controller = $this->newGraphQLiteController(); 186 | 187 | $response = $controller->index($this->newRequest()); 188 | 189 | $this->assertEquals(418, $response->getStatusCode()); 190 | } 191 | 192 | private function newCodeDecider(int $statusCode): HttpCodeDeciderInterface 193 | { 194 | return new class implements HttpCodeDeciderInterface { 195 | public function decideHttpStatusCode(ExecutionResult $result): int 196 | { 197 | return 418; 198 | } 199 | }; 200 | } 201 | 202 | private function newGraphQLiteController(): GraphQLiteController 203 | { 204 | $server = $this->app->make(StandardServer::class); 205 | $httpCodeDecider = $this->app->make(HttpCodeDeciderInterface::class); 206 | $messageFactory = $this->app->make(PsrHttpFactory::class); 207 | return new GraphQLiteController($server, $httpCodeDecider, $messageFactory, DebugFlag::RETHROW_UNSAFE_EXCEPTIONS); 208 | } 209 | 210 | private function newRequest(): Request 211 | { 212 | $baseRequest = SymfonyRequest::create('https://localhost', 'GET', [ 213 | 'query' => '{ testValidatorMultiple(foo:"192.168.1.1") }' 214 | ]); 215 | 216 | return Request::createFromBase($baseRequest); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/Providers/GraphQLiteServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 63 | __DIR__.'/../../config/graphqlite.php' => config_path('graphqlite.php'), 64 | ], 'config'); 65 | 66 | $this->loadRoutesFrom(__DIR__.'/../routes/routes.php'); 67 | $events->listen('cache:clearing', CachePurger::class); 68 | } 69 | 70 | /** 71 | * Register the application services. 72 | * 73 | * @return void 74 | */ 75 | public function register() 76 | { 77 | $this->commands([ 78 | GraphqliteExportSchema::class, 79 | ]); 80 | 81 | $this->app->bind(WebonyxSchema::class, Schema::class); 82 | 83 | if (!$this->app->has(ServerRequestFactoryInterface::class)) { 84 | $this->app->bind(ServerRequestFactoryInterface::class, ServerRequestFactory::class); 85 | } 86 | if (!$this->app->has(StreamFactoryInterface::class)) { 87 | $this->app->bind(StreamFactoryInterface::class, StreamFactory::class); 88 | } 89 | if (!$this->app->has(UploadedFileFactoryInterface::class)) { 90 | $this->app->bind(UploadedFileFactoryInterface::class, UploadedFileFactory::class); 91 | } 92 | if (!$this->app->has(ResponseFactoryInterface::class)) { 93 | $this->app->bind(ResponseFactoryInterface::class, ResponseFactory::class); 94 | } 95 | if (!$this->app->has(HttpCodeDeciderInterface::class)) { 96 | $this->app->bind(HttpCodeDeciderInterface::class, HttpCodeDecider::class); 97 | } 98 | 99 | $this->app->bind(HttpMessageFactoryInterface::class, PsrHttpFactory::class); 100 | 101 | $this->app->singleton(GraphQLiteController::class, function (Application $app) { 102 | $debug = config('graphqlite.debug', DebugFlag::RETHROW_UNSAFE_EXCEPTIONS); 103 | $decider = config('graphqlite.http_code_decider'); 104 | if (!$decider) { 105 | $httpCodeDecider = $app[HttpCodeDeciderInterface::class]; 106 | } else { 107 | $httpCodeDecider = $app[$decider]; 108 | } 109 | 110 | return new GraphQLiteController($app[StandardServer::class], $httpCodeDecider, $app[HttpMessageFactoryInterface::class], $debug); 111 | }); 112 | 113 | $this->app->singleton(StandardServer::class, static function (Application $app) { 114 | return new StandardServer($app[ServerConfig::class]); 115 | }); 116 | 117 | $this->app->singleton(ServerConfig::class, static function (Application $app) { 118 | $serverConfig = new ServerConfig(); 119 | $serverConfig->setSchema($app[Schema::class]); 120 | $serverConfig->setErrorFormatter([WebonyxErrorHandler::class, 'errorFormatter']); 121 | $serverConfig->setErrorsHandler([WebonyxErrorHandler::class, 'errorHandler']); 122 | $serverConfig->setContext(new Context()); 123 | return $serverConfig; 124 | }); 125 | 126 | $this->app->singleton('graphqliteCache', static function () { 127 | if (extension_loaded('apcu') && ini_get('apc.enabled')) { 128 | return new Psr16Cache(new ApcuAdapter()); 129 | } else { 130 | return new Psr16Cache(new PhpFilesAdapter()); 131 | } 132 | }); 133 | 134 | $this->app->singleton(CachePurger::class, static function (Application $app) { 135 | return new CachePurger($app['graphqliteCache']); 136 | }); 137 | 138 | $this->app->singleton(AuthenticationService::class, function(Application $app) { 139 | $guard = config('graphqlite.guard', $this->app['config']['auth.defaults.guard']); 140 | if (!is_array($guard)) { 141 | $guard = [$guard]; 142 | } 143 | return new AuthenticationService($app[AuthFactory::class], $guard); 144 | }); 145 | 146 | $this->app->bind(AuthenticationServiceInterface::class, AuthenticationService::class); 147 | 148 | $this->app->singleton(SchemaFactory::class, function (Application $app) { 149 | $service = new SchemaFactory($app->make('graphqliteCache'), new SanePsr11ContainerAdapter($app)); 150 | $service->setAuthenticationService($app[AuthenticationService::class]); 151 | $service->setAuthorizationService($app[AuthorizationService::class]); 152 | $service->addParameterMiddleware($app[ValidateFieldMiddleware::class]); 153 | 154 | $service->addTypeMapperFactory($app[PaginatorTypeMapperFactory::class]); 155 | 156 | $controllers = config('graphqlite.controllers', 'App\\Http\\Controllers'); 157 | if (!is_iterable($controllers)) { 158 | $controllers = [ $controllers ]; 159 | } 160 | $types = config('graphqlite.types', 'App\\'); 161 | if (!is_iterable($types)) { 162 | $types = [ $types ]; 163 | } 164 | foreach ($controllers as $namespace) { 165 | $service->addControllerNamespace($namespace); 166 | } 167 | foreach ($types as $namespace) { 168 | $service->addTypeNamespace($namespace); 169 | } 170 | 171 | if ($this->app->environment('production')) { 172 | $service->prodMode(); 173 | } 174 | 175 | return $service; 176 | }); 177 | 178 | $this->app->singleton(Schema::class, function (Application $app) { 179 | /** @var SchemaFactory $schemaFactory */ 180 | $schemaFactory = $app->make(SchemaFactory::class); 181 | 182 | return $schemaFactory->createSchema(); 183 | }); 184 | } 185 | 186 | /** 187 | * Get the services provided by the provider. 188 | * 189 | * @return array 190 | */ 191 | public function provides() 192 | { 193 | return [ 194 | SchemaFactory::class, 195 | Schema::class, 196 | ]; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/Mappers/PaginatorTypeMapper.php: -------------------------------------------------------------------------------- 1 | */ 33 | private $cache = []; 34 | /** @var RecursiveTypeMapperInterface */ 35 | private $recursiveTypeMapper; 36 | 37 | public function __construct(RecursiveTypeMapperInterface $recursiveTypeMapper) 38 | { 39 | $this->recursiveTypeMapper = $recursiveTypeMapper; 40 | } 41 | 42 | /** 43 | * Returns true if this type mapper can map the $className FQCN to a GraphQL type. 44 | * 45 | * @param string $className The exact class name to look for (this function does not look into parent classes). 46 | */ 47 | public function canMapClassToType(string $className): bool 48 | { 49 | return is_a($className, Paginator::class, true); 50 | } 51 | 52 | /** 53 | * Maps a PHP fully qualified class name to a GraphQL type. 54 | * 55 | * @param string $className The exact class name to look for (this function does not look into parent classes). 56 | * @param (OutputType&Type)|null $subType An optional sub-type if the main class is an iterator that needs to be typed. 57 | * 58 | * @return MutableObjectType|MutableInterfaceType 59 | * 60 | * @throws CannotMapTypeExceptionInterface 61 | */ 62 | public function mapClassToType(string $className, ?OutputType $subType): MutableInterface 63 | { 64 | if (! $this->canMapClassToType($className)) { 65 | throw CannotMapTypeException::createForType($className); 66 | } 67 | if ($subType === null) { 68 | throw PaginatorMissingParameterException::noSubType(); 69 | } 70 | 71 | return $this->getObjectType(is_a($className, LengthAwarePaginator::class, true), $subType); 72 | } 73 | 74 | /** 75 | * @param OutputType&Type $subType 76 | * 77 | * @return MutableObjectType|MutableInterfaceType 78 | */ 79 | private function getObjectType(bool $countable, OutputType $subType): MutableInterface 80 | { 81 | if (! isset($subType->name)) { 82 | throw new RuntimeException('Cannot get name property from sub type ' . get_class($subType)); 83 | } 84 | 85 | $name = $subType->name; 86 | 87 | $typeName = 'PaginatorResult_' . $name; 88 | 89 | if ($subType instanceof NullableType) { 90 | $subType = Type::nonNull($subType); 91 | } 92 | 93 | if (! isset($this->cache[$typeName])) { 94 | $this->cache[$typeName] = new MutableObjectType([ 95 | 'name' => $typeName, 96 | 'fields' => static function () use ($subType, $countable) { 97 | $fields = [ 98 | 'items' => [ 99 | 'type' => Type::nonNull(Type::listOf($subType)), 100 | 'resolve' => static function (Paginator $root) { 101 | return $root->items(); 102 | }, 103 | ], 104 | 'firstItem' => [ 105 | 'type' => Type::int(), 106 | 'description' => 'Get the "index" of the first item being paginated.', 107 | 'resolve' => static function (Paginator $root): int { 108 | return $root->firstItem(); 109 | }, 110 | ], 111 | 'lastItem' => [ 112 | 'type' => Type::int(), 113 | 'description' => 'Get the "index" of the last item being paginated.', 114 | 'resolve' => static function (Paginator $root): int { 115 | return $root->lastItem(); 116 | }, 117 | ], 118 | 'hasMorePages' => [ 119 | 'type' => Type::boolean(), 120 | 'description' => 'Determine if there are more items in the data source.', 121 | 'resolve' => static function (Paginator $root): bool { 122 | return $root->hasMorePages(); 123 | }, 124 | ], 125 | 'perPage' => [ 126 | 'type' => Type::int(), 127 | 'description' => 'Get the number of items shown per page.', 128 | 'resolve' => static function (Paginator $root): int { 129 | return $root->perPage(); 130 | }, 131 | ], 132 | 'hasPages' => [ 133 | 'type' => Type::boolean(), 134 | 'description' => 'Determine if there are enough items to split into multiple pages.', 135 | 'resolve' => static function (Paginator $root): bool { 136 | return $root->hasPages(); 137 | }, 138 | ], 139 | 'currentPage' => [ 140 | 'type' => Type::int(), 141 | 'description' => 'Determine the current page being paginated.', 142 | 'resolve' => static function (Paginator $root): int { 143 | return $root->currentPage(); 144 | }, 145 | ], 146 | 'isEmpty' => [ 147 | 'type' => Type::boolean(), 148 | 'description' => 'Determine if the list of items is empty or not.', 149 | 'resolve' => static function (Paginator $root): bool { 150 | return $root->isEmpty(); 151 | }, 152 | ], 153 | 'isNotEmpty' => [ 154 | 'type' => Type::boolean(), 155 | 'description' => 'Determine if the list of items is not empty.', 156 | 'resolve' => static function (Paginator $root): bool { 157 | return $root->isNotEmpty(); 158 | }, 159 | ], 160 | ]; 161 | 162 | if ($countable) { 163 | $fields['totalCount'] = [ 164 | 'type' => Type::int(), 165 | 'description' => 'The total count of items.', 166 | 'resolve' => static function (LengthAwarePaginator $root): int { 167 | return $root->total(); 168 | }]; 169 | $fields['lastPage'] = [ 170 | 'type' => Type::int(), 171 | 'description' => 'Get the page number of the last available page.', 172 | 'resolve' => static function (LengthAwarePaginator $root): int { 173 | return $root->lastPage(); 174 | }]; 175 | } 176 | 177 | return $fields; 178 | }, 179 | ]); 180 | } 181 | 182 | return $this->cache[$typeName]; 183 | } 184 | 185 | /** 186 | * Returns true if this type mapper can map the $typeName GraphQL name to a GraphQL type. 187 | * 188 | * @param string $typeName The name of the GraphQL type 189 | */ 190 | public function canMapNameToType(string $typeName): bool 191 | { 192 | return strpos($typeName, 'PaginatorResult_') === 0 || strpos($typeName, 'LengthAwarePaginatorResult_') === 0; 193 | } 194 | 195 | /** 196 | * Returns a GraphQL type by name (can be either an input or output type) 197 | * 198 | * @param string $typeName The name of the GraphQL type 199 | * 200 | * @return Type&NamedType&((ResolvableMutableInputInterface&InputObjectType)|MutableObjectType|MutableInterfaceType) 201 | * 202 | * @throws CannotMapTypeExceptionInterface 203 | */ 204 | public function mapNameToType(string $typeName): Type&NamedType 205 | { 206 | if (strpos($typeName, 'LengthAwarePaginatorResult_') === 0) { 207 | $subTypeName = substr($typeName, 27); 208 | $lengthAware = true; 209 | } elseif (strpos($typeName, 'PaginatorResult_') === 0) { 210 | $subTypeName = substr($typeName, 16); 211 | $lengthAware = false; 212 | } else { 213 | throw CannotMapTypeException::createForName($typeName); 214 | } 215 | 216 | $subType = $this->recursiveTypeMapper->mapNameToType($subTypeName); 217 | 218 | if (! $subType instanceof OutputType) { 219 | throw CannotMapTypeException::mustBeOutputType($subTypeName); 220 | } 221 | 222 | return $this->getObjectType($lengthAware, $subType); 223 | } 224 | 225 | /** 226 | * Returns the list of classes that have matching input GraphQL types. 227 | * 228 | * @return string[] 229 | */ 230 | public function getSupportedClasses(): array 231 | { 232 | // We cannot get the list of all possible porpaginas results but this is not an issue. 233 | // getSupportedClasses is only useful to get classes that can be hidden behind interfaces 234 | // and Porpaginas results are not part of those. 235 | return []; 236 | } 237 | 238 | /** 239 | * Returns true if this type mapper can map the $className FQCN to a GraphQL input type. 240 | */ 241 | public function canMapClassToInputType(string $className): bool 242 | { 243 | return false; 244 | } 245 | 246 | /** 247 | * Maps a PHP fully qualified class name to a GraphQL input type. 248 | * 249 | * @return ResolvableMutableInputInterface&InputObjectType 250 | */ 251 | public function mapClassToInputType(string $className): ResolvableMutableInputInterface 252 | { 253 | throw CannotMapTypeException::createForInputType($className); 254 | } 255 | 256 | /** 257 | * Returns true if this type mapper can extend an existing type for the $className FQCN 258 | * 259 | * @param MutableInterface&(MutableObjectType|MutableInterfaceType) $type 260 | */ 261 | public function canExtendTypeForClass(string $className, MutableInterface $type): bool 262 | { 263 | return false; 264 | } 265 | 266 | /** 267 | * Extends the existing GraphQL type that is mapped to $className. 268 | * 269 | * @param MutableInterface&(MutableObjectType|MutableInterfaceType) $type 270 | * 271 | * @throws CannotMapTypeExceptionInterface 272 | */ 273 | public function extendTypeForClass(string $className, MutableInterface $type): void 274 | { 275 | throw CannotMapTypeException::createForExtendType($className, $type); 276 | } 277 | 278 | /** 279 | * Returns true if this type mapper can extend an existing type for the $typeName GraphQL type 280 | * 281 | * @param MutableInterface&(MutableObjectType|MutableInterfaceType) $type 282 | */ 283 | public function canExtendTypeForName(string $typeName, MutableInterface $type): bool 284 | { 285 | return false; 286 | } 287 | 288 | /** 289 | * Extends the existing GraphQL type that is mapped to the $typeName GraphQL type. 290 | * 291 | * @param MutableInterface&(MutableObjectType|MutableInterfaceType) $type 292 | * 293 | * @throws CannotMapTypeExceptionInterface 294 | */ 295 | public function extendTypeForName(string $typeName, MutableInterface $type): void 296 | { 297 | throw CannotMapTypeException::createForExtendName($typeName, $type); 298 | } 299 | 300 | /** 301 | * Returns true if this type mapper can decorate an existing input type for the $typeName GraphQL input type 302 | */ 303 | public function canDecorateInputTypeForName(string $typeName, ResolvableMutableInputInterface $type): bool 304 | { 305 | return false; 306 | } 307 | 308 | /** 309 | * Decorates the existing GraphQL input type that is mapped to the $typeName GraphQL input type. 310 | * 311 | * @param ResolvableMutableInputInterface&InputObjectType $type 312 | * 313 | * @throws CannotMapTypeExceptionInterface 314 | */ 315 | public function decorateInputTypeForName(string $typeName, ResolvableMutableInputInterface $type): void 316 | { 317 | throw CannotMapTypeException::createForDecorateName($typeName, $type); 318 | } 319 | } 320 | --------------------------------------------------------------------------------