├── .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 | [](https://packagist.org/packages/thecodingmachine/graphqlite-laravel)
2 | [](https://packagist.org/packages/thecodingmachine/graphqlite-laravel)
3 | [](https://packagist.org/packages/thecodingmachine/graphqlite-laravel)
4 | [](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 |
--------------------------------------------------------------------------------