diff --git a/composer.json b/composer.json index 724a512..c9128ce 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ "autoload-dev": { "psr-4": { "GraphQL\\Tests\\": "tests/", - "GraphQL\\Benchmarks\\": "benchmarks/" + "GraphQL\\Benchmarks\\": "benchmarks/", + "GraphQL\\Examples\\Blog\\": "examples/01-blog/Blog/" } } } diff --git a/examples/01-blog/Blog/AppContext.php b/examples/01-blog/Blog/AppContext.php new file mode 100644 index 0000000..3c43858 --- /dev/null +++ b/examples/01-blog/Blog/AppContext.php @@ -0,0 +1,35 @@ +users = [ + 1 => new User([ + 'id' => 1, + 'email' => 'john@example.com', + 'firstName' => 'John', + 'lastName' => 'Doe' + ]), + 2 => new User([ + 'id' => 2, + 'email' => 'jane@example.com', + 'firstName' => 'Jane', + 'lastName' => 'Doe' + ]), + 3 => new User([ + 'id' => 3, + 'email' => 'john@example.com', + 'firstName' => 'John', + 'lastName' => 'Doe' + ]), + ]; + + $this->stories = [ + 1 => new Story(['id' => 1, 'authorId' => 1]), + 2 => new Story(['id' => 2, 'authorId' => 1]), + 3 => new Story(['id' => 3, 'authorId' => 3]), + ]; + + $this->storyLikes = [ + 1 => [1, 2, 3], + 2 => [], + 3 => [1] + ]; + } + + public function findUser($id) + { + return isset($this->users[$id]) ? $this->users[$id] : null; + } + + public function findStory($id) + { + return isset($this->stories[$id]) ? $this->stories[$id] : null; + } + + public function findLastStoryFor($authorId) + { + $storiesFound = array_filter($this->stories, function(Story $story) use ($authorId) { + return $story->authorId == $authorId; + }); + return !empty($storiesFound) ? $storiesFound[count($storiesFound) - 1] : null; + } + + public function isLikedBy(Story $story, User $user) + { + $subscribers = isset($this->storyLikes[$story->id]) ? $this->storyLikes[$story->id] : []; + return in_array($user->id, $subscribers); + } + + public function getUserPhoto(User $user, $size) + { + return new Image([ + 'id' => $user->id, + 'type' => Image::TYPE_USERPIC, + 'size' => $size, + 'width' => rand(100, 200), + 'height' => rand(100, 200) + ]); + } + + public function findLatestStory() + { + return array_pop($this->stories); + } +} diff --git a/examples/01-blog/Blog/Data/Image.php b/examples/01-blog/Blog/Data/Image.php new file mode 100644 index 0000000..651629f --- /dev/null +++ b/examples/01-blog/Blog/Data/Image.php @@ -0,0 +1,29 @@ + 'ImageSizeEnum', + 'values' => [ + 'ICON' => Image::SIZE_ICON, + 'SMALL' => Image::SIZE_SMALL, + 'MEDIUM' => Image::SIZE_MEDIUM, + 'ORIGINAL' => Image::SIZE_ORIGINAL + ] + ]); + } +} diff --git a/examples/01-blog/Blog/Type/ImageType.php b/examples/01-blog/Blog/Type/ImageType.php new file mode 100644 index 0000000..f5456e1 --- /dev/null +++ b/examples/01-blog/Blog/Type/ImageType.php @@ -0,0 +1,54 @@ + 'ImageType', + 'fields' => [ + 'id' => $types->id(), + 'type' => new EnumType([ + 'name' => 'ImageTypeEnum', + 'values' => [ + 'USERPIC' => Image::TYPE_USERPIC + ] + ]), + 'size' => $types->imageSizeEnum(), + 'width' => $types->int(), + 'height' => $types->int(), + 'url' => [ + 'type' => $types->url(), + 'resolve' => [$handler, 'resolveUrl'] + ], + 'error' => [ + 'type' => $types->string(), + 'resolve' => function() { + throw new \Exception("This is error field"); + } + ] + ] + ]); + } + + public function resolveUrl(Image $value, $args, AppContext $context) + { + switch ($value->type) { + case Image::TYPE_USERPIC: + $path = "/images/user/{$value->id}-{$value->size}.jpg"; + break; + default: + throw new \UnexpectedValueException("Unexpected image type: " . $value->type); + } + return $context->rootUrl . $path; + } +} diff --git a/examples/01-blog/Blog/Type/NodeType.php b/examples/01-blog/Blog/Type/NodeType.php new file mode 100644 index 0000000..6b4c155 --- /dev/null +++ b/examples/01-blog/Blog/Type/NodeType.php @@ -0,0 +1,39 @@ + 'Node', + 'fields' => [ + 'id' => $types->id() + ], + 'resolveType' => function ($object) use ($types) { + return self::resolveType($object, $types); + } + ]); + } + + public static function resolveType($object, TypeSystem $types) + { + if ($object instanceof User) { + return $types->user(); + } else if ($object instanceof Image) { + return $types->image(); + } else if ($object instanceof Story) { + return $types->story(); + } + } +} diff --git a/examples/01-blog/Blog/Type/QueryType.php b/examples/01-blog/Blog/Type/QueryType.php new file mode 100644 index 0000000..58d2dfe --- /dev/null +++ b/examples/01-blog/Blog/Type/QueryType.php @@ -0,0 +1,54 @@ + 'Query', + 'fields' => [ + 'user' => [ + 'type' => $types->user(), + 'args' => [ + 'id' => [ + 'type' => $types->id(), + 'defaultValue' => 1 + ] + ] + ], + 'viewer' => $types->user(), + 'lastStoryPosted' => $types->story(), + 'stories' => [ + 'type' => $types->listOf($types->story()), + 'args' => [] + ] + ], + 'resolveField' => function($val, $args, $context, ResolveInfo $info) use ($handler) { + return $handler->{$info->fieldName}($val, $args, $context, $info); + } + ]); + } + + public function user($val, $args, AppContext $context) + { + return $context->dataSource->findUser($args['id']); + } + + public function viewer($val, $args, AppContext $context) + { + return $context->viewer; + } + + public function lastStoryPosted($val, $args, AppContext $context) + { + return $context->dataSource->findLatestStory(); + } +} diff --git a/examples/01-blog/Blog/Type/Scalar/EmailType.php b/examples/01-blog/Blog/Type/Scalar/EmailType.php new file mode 100644 index 0000000..4154043 --- /dev/null +++ b/examples/01-blog/Blog/Type/Scalar/EmailType.php @@ -0,0 +1,60 @@ +coerceEmail($value); + } + + /** + * Parses an externally provided value to use as an input + * + * @param mixed $value + * @return mixed + */ + public function parseValue($value) + { + return $this->coerceEmail($value); + } + + private function coerceEmail($value) + { + if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { + throw new \UnexpectedValueException("Cannot represent value as email: " . Utils::printSafe($value)); + } + return $value; + } + + /** + * Parses an externally provided literal value to use as an input + * + * @param \GraphQL\Language\AST\Value $valueAST + * @return mixed + */ + public function parseLiteral($valueAST) + { + if ($valueAST instanceof StringValue) { + return $valueAST->value; + } + return null; + } +} diff --git a/examples/01-blog/Blog/Type/Scalar/UrlType.php b/examples/01-blog/Blog/Type/Scalar/UrlType.php new file mode 100644 index 0000000..f1aa508 --- /dev/null +++ b/examples/01-blog/Blog/Type/Scalar/UrlType.php @@ -0,0 +1,64 @@ +coerceUrl($value); + } + + /** + * Parses an externally provided value to use as an input + * + * @param mixed $value + * @return mixed + */ + public function parseValue($value) + { + return $this->coerceUrl($value); + } + + /** + * @param $value + * @return float|null + */ + private function coerceUrl($value) + { + if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_URL)) { // quite naive, but after all this is example + throw new \UnexpectedValueException("Cannot represent value as URL: " . Utils::printSafe($value)); + } + return $value; + } + + /** + * @return null|string + */ + public function parseLiteral($ast) + { + if ($ast instanceof StringValue) { + return $ast->value; + } + return null; + } +} diff --git a/examples/01-blog/Blog/Type/StoryType.php b/examples/01-blog/Blog/Type/StoryType.php new file mode 100644 index 0000000..a4408b6 --- /dev/null +++ b/examples/01-blog/Blog/Type/StoryType.php @@ -0,0 +1,114 @@ + 'Story', + 'fields' => function() use ($types) { + return [ + 'id' => $types->id(), + 'author' => $types->user(), + 'body' => [ + 'type' => $types->string(), + 'args' => [ + 'format' => new EnumType([ + 'name' => 'StoryFormatEnum', + 'values' => [self::FORMAT_TEXT, self::FORMAT_HTML] + ]), + 'maxLength' => $types->int() + ] + ], + 'isLiked' => $types->boolean(), + 'affordances' => $types->listOf(new EnumType([ + 'name' => 'StoryAffordancesEnum', + 'values' => [ + self::EDIT, + self::DELETE, + self::LIKE, + self::UNLIKE + ] + ])) + ]; + }, + 'interfaces' => [ + $types->node() + ], + 'resolveField' => function($value, $args, $context, ResolveInfo $info) use ($handler) { + if (method_exists($handler, $info->fieldName)) { + return $handler->{$info->fieldName}($value, $args, $context, $info); + } else { + return $value->{$info->fieldName}; + } + }, + 'containerType' => $handler + ]); + } + + /** + * @param Story $story + * @param $args + * @param AppContext $context + * @return User|null + */ + public function author(Story $story, $args, AppContext $context) + { + if ($story->isAnonymous) { + return null; + } + return $context->dataSource->findUser($story->authorId); + } + + /** + * @param Story $story + * @param $args + * @param AppContext $context + * @return array + */ + public function affordances(Story $story, $args, AppContext $context) + { + $isViewer = $context->viewer === $context->dataSource->findUser($story->authorId); + $isLiked = $context->dataSource->isLikedBy($story, $context->viewer); + + if ($isViewer) { + $affordances[] = self::EDIT; + $affordances[] = self::DELETE; + } + if ($isLiked) { + $affordances[] = self::UNLIKE; + } else { + $affordances[] = self::LIKE; + } + return $affordances; + } +} diff --git a/examples/01-blog/Blog/Type/UserType.php b/examples/01-blog/Blog/Type/UserType.php new file mode 100644 index 0000000..0db4fe1 --- /dev/null +++ b/examples/01-blog/Blog/Type/UserType.php @@ -0,0 +1,66 @@ + 'User', + 'fields' => function() use ($types) { + return [ + 'id' => $types->id(), + 'email' => $types->email(), + 'photo' => [ + 'type' => $types->image(), + 'description' => 'User photo URL', + 'args' => [ + 'size' => $types->nonNull($types->imageSizeEnum()), + ] + ], + 'firstName' => [ + 'type' => $types->string(), + ], + 'lastName' => [ + 'type' => $types->string(), + ], + 'lastStoryPosted' => $types->story(), + 'error' => [ + 'type' => $types->string(), + 'resolve' => function() { + throw new \Exception("This is error field"); + } + ] + ]; + }, + 'interfaces' => [ + $types->node() + ], + 'resolveField' => function($value, $args, $context, ResolveInfo $info) use ($handler) { + if (method_exists($handler, $info->fieldName)) { + return $handler->{$info->fieldName}($value, $args, $context, $info); + } else { + return $value->{$info->fieldName}; + } + } + ]); + } + + public function photo(User $user, $args, AppContext $context) + { + return $context->dataSource->getUserPhoto($user, $args['size']); + } + + public function lastStoryPosted(User $user, $args, AppContext $context) + { + return $context->dataSource->findLastStoryFor($user->id); + } +} diff --git a/examples/01-blog/Blog/TypeSystem.php b/examples/01-blog/Blog/TypeSystem.php new file mode 100644 index 0000000..71b91ec --- /dev/null +++ b/examples/01-blog/Blog/TypeSystem.php @@ -0,0 +1,165 @@ +story ?: ($this->story = StoryType::getDefinition($this)); + } + + /** + * @return ObjectType + */ + public function user() + { + return $this->user ?: ($this->user = UserType::getDefinition($this)); + } + + /** + * @return ObjectType + */ + public function image() + { + return $this->image ?: ($this->image = ImageType::getDefinition($this)); + } + + /** + * @return ObjectType + */ + public function query() + { + return $this->query ?: ($this->query = QueryType::getDefinition($this)); + } + + + // Interfaces + private $nodeDefinition; + + /** + * @return \GraphQL\Type\Definition\InterfaceType + */ + public function node() + { + return $this->nodeDefinition ?: ($this->nodeDefinition = NodeType::getDefinition($this)); + } + + + // Enums + private $imageSizeEnum; + + /** + * @return EnumType + */ + public function imageSizeEnum() + { + return $this->imageSizeEnum ?: ($this->imageSizeEnum = ImageSizeEnumType::getDefinition()); + } + + // Custom Scalar types: + private $urlType; + private $emailType; + + public function email() + { + return $this->emailType ?: ($this->emailType = EmailType::create()); + } + + /** + * @return UrlType + */ + public function url() + { + return $this->urlType ?: ($this->urlType = UrlType::create()); + } + + + // Let's add internal types as well for consistent experience + + public function boolean() + { + return Type::boolean(); + } + + /** + * @return \GraphQL\Type\Definition\FloatType + */ + public function float() + { + return Type::float(); + } + + /** + * @return \GraphQL\Type\Definition\IDType + */ + public function id() + { + return Type::id(); + } + + /** + * @return \GraphQL\Type\Definition\IntType + */ + public function int() + { + return Type::int(); + } + + /** + * @return \GraphQL\Type\Definition\StringType + */ + public function string() + { + return Type::string(); + } + + /** + * @param Type $type + * @return ListOfType + */ + public function listOf($type) + { + return new ListOfType($type); + } + + /** + * @param $type + * @return NonNull + */ + public function nonNull($type) + { + return new NonNull($type); + } +} diff --git a/examples/01-blog/README.md b/examples/01-blog/README.md new file mode 100644 index 0000000..f25e8a0 --- /dev/null +++ b/examples/01-blog/README.md @@ -0,0 +1,61 @@ +## Blog Example + +Simple but full-featured example of GraphQL API. Models simple blog with Stories and Users. + +Note that graphql-php doesn't dictate you how to structure your application or data layer. +You may choose the way of using the library as you prefer. + +Best practices in GraphQL world still emerge, so feel free to post your proposals or own +examples as PRs. + +### Running locally +``` +php -S localhost:8080 ./index.php +``` + +### Browsing API +The most convenient way to browse GraphQL API is by using [GraphiQL](https://github.com/graphql/graphiql) +But setting it up from scratch may be inconvenient. The great and easy alternative is to use one of +existing Google Chrome extensions: +- [ChromeiQL](https://chrome.google.com/webstore/detail/chromeiql/fkkiamalmpiidkljmicmjfbieiclmeij) +- [GraphiQL Feen](https://chrome.google.com/webstore/detail/graphiql-feen/mcbfdonlkfpbfdpimkjilhdneikhfklp) + +Note that these extensions may be out of date, but most of the time they Just Work(TM) + +Set up `http://localhost:8080?debug=0` as your GraphQL endpoint/server in these extensions and +execute following query: + +``` +{ + viewer { + id + email + } + lastStoryPosted { + id + isLiked + author { + id + photo(size: ICON) { + id + url + type + size + width + height + # Uncomment to see error reporting for failed resolvers + # error + } + lastStoryPosted { + id + } + } + } +} +``` + +### Debugging +By default this example runs in production mode without additional debugging tools enabled. + +In order to enable debugging mode with additional validation of type definition configs, +PHP errors handling and reporting - change your endpoint to `http://localhost:8080?debug=1` diff --git a/examples/01-blog/index.php b/examples/01-blog/index.php new file mode 100644 index 0000000..22a1cd0 --- /dev/null +++ b/examples/01-blog/index.php @@ -0,0 +1,83 @@ +viewer = $dataSource->findUser(1); // simulated "currently logged-in user" + $appContext->dataSource = $dataSource; + $appContext->rootUrl = 'http://localhost:8080'; + $appContext->request = $_REQUEST; + + // Parse incoming query and variables + if (isset($_SERVER['CONTENT_TYPE']) && strpos($_SERVER['CONTENT_TYPE'], 'application/json') !== false) { + $raw = file_get_contents('php://input') ?: ''; + $data = json_decode($raw, true); + } else { + $data = $_REQUEST; + } + $data += ['query' => null, 'variables' => null]; + + // GraphQL schema to be passed to query executor: + $schema = new Schema([ + 'query' => $typeSystem->query() + ]); + + $result = GraphQL::execute( + $schema, + $data['query'], + null, + $appContext, + (array) $data['variables'] + ); + + // Add reported PHP errors to result (if any) + if (!empty($_GET['debug']) && !empty($phpErrors)) { + $result['extensions']['phpErrors'] = array_map( + ['GraphQL\Error\FormattedError', 'createFromPHPError'], + $phpErrors + ); + } + $httpStatus = 200; +} catch (\Exception $error) { + $httpStatus = 500; + if (!empty($_GET['debug'])) { + $result['extensions']['exception'] = \GraphQL\Error\FormattedError::createFromException($error); + } else { + $result['errors'] = \GraphQL\Error\FormattedError::create('Unexpected Error'); + } +} + +header('Content-Type: application/json', true, $httpStatus); +echo json_encode($result);