Hack-GraphQL is the first (and currently only) open-source GraphQL server written in Hack. It provides a developer-first approach to declaring a GraphQL schema as Hack code, using attributes and code generation. While not yet fully-featured, Hack-GraphQL supports most of the GraphQL spec.
- Code-first approach: declare GraphQL types and fields as Hack code.
- Strongly typed: generated code is 100% type-safe.
- Supports nearly all GraphQL types: objects, interfaces, enumerations, directives, lists, and input objects.
- Support for introspection.
- Ships with support for the Relay model of pagination.
- Subscriptions
- Only a subset of GraphQL's validation rules are implemented. To see which rules have been implemented, take a look at the
src/Validation/Rules
directory. - It is not possible to declare custom directives. Only the
include
andskip
directives are supported. - No support for unions (because Hack does not support them).
In Hack-GraphQL, we represent GraphQL types and fields as ordinary Hack classes and methods. To create a new GraphQL type, we declare a Hack class and annotate it with the <<GraphQL\ObjectType>>
attribute. To add a field to the type, we add a method annotated with <<GraphQL\Field>>
to the Hack class.
For example, here's how we might declare a type called "User" with a "name" field:
use namespace Slack\GraphQL;
<<GraphQL\ObjectType('User', 'A user of our app')>>
final class User {
public function __construct(private string $name) {}
<<GraphQL\Field('name', 'The name of the user')>>
public function getName(): string {
return $this->name;
}
}
Hack-GraphQL supports all GraphQL types except unions. See Attributes for details on generating instances of each type from Hack code.
Now we need to expose a top-level query which can retrieve our new type:
// This could be called anything - it's just a static
// class which contains the top-level query methods.
final class QueryFields {
<<GraphQL\QueryRootField('getUser', 'Get the user')>>
public static function getUser(string $name): User {
return new User($name);
}
}
Once you've defined your types, you'll run Hack-GraphQL's code generator to create resolvers for them:
use namespace Slack\GraphQL;
await GraphQL\Codegen\Generator::forPath(
'/path/to/your/user/defined/types',
shape(
'output_directory' => '/path/for/your/generated/code',
'namespace' => 'Generated',
),
);
Running the above code will write resolvers for the types defined in /path/to/your/user/defined/types
to the /path/for/your/generated/code
directory. These resolvers are generated code — you are not meant to edit them.
Hack-GraphQL will also generate a top-level Schema
type which you can use to resolve queries and mutations, as follows:
use namespace Slack\GraphQL;
$resolver = new GraphQL\Resolver(new Generated\Schema());
$response = await $resolver->resolve($query);
In the above:
GraphQL\Resolver
is a class provided by Hack-GraphQL which handles resolving queries and mutations using the generated schema.$query
is the string representation of a GraphQL query. The resolver will parse the query, validate it, and then use the schema to resolve it.$response
is a shape containing data, errors, and extension fields, as required by the GraphQL spec.
And that's it! You're now ready to serve GraphQL queries and mutations using your new schema.
Hack-GraphQL exposes the following attributes which can be used to create GraphQL types and fields:
This attribute annotates a non-abstract class to declare a GraphQL object:
use namespace Slack\GraphQL;
<<GraphQL\ObjectType('User', 'A user of our app')>>
final class User {
<<GraphQL\Field('name', 'Name of the user')>>
public function getName(): string {
return 'Steve';
}
}
Note you may also declare GraphQL types by annotating Hack shapes with ObjectType
. This can be a convenient shorthand:
<<GraphQL\ObjectType('User', 'A user of our app')>>
type User = shape(
'name' => string,
);
The disadvantage of annotating shapes in this way is that they don't currently support descriptions of their fields. This is something we'd like to address.
This attribute declares a GraphQL field by annotating a non-static method on a class or interface annotated with GraphQL\ObjectType
or GraphQL\InterfaceType
:
use namespace Slack\GraphQL;
<<GraphQL\ObjectType('User', 'A user of our app')>>
final class User {
<<GraphQL\Field('name', 'Name of the user')>>
public function getName(): string {
return 'Steve';
}
<<GraphQL\Field('age', 'Age of the user')>>
public function getAge(): int {
return 45;
}
}
This attribute declares a GraphQL query by annotating a static method on any class:
use namespace Slack\GraphQL;
final class QueryFields {
<<GraphQL\QueryRootField('user', 'Retrive a user by its name.')>>
public static async function getUserByName(string $name): Awaitable<?User> {
return await User::fetchByName($name);
}
}
A GraphQL query written against this field might look like:
{
user(name: 'Steve') {
age
favoriteColor
}
}
This attribute declares a GraphQL mutation by annotating a static method on any class:
use namespace Slack\GraphQL;
final class MutationFields {
<<GraphQL\MutationRootField('createUser', 'Create a user')>>
public static async function createUser(string $name): Awaitable<?User> {
return await User::create($name);
}
}
A GraphQL mutation written against this field might look like:
{
createUser(name: 'Steve') {
id
}
}
This attribute annotates an abstract class or interface to declare a GraphQL interface:
use namespace Slack\GraphQL;
<<GraphQL\InterfaceType('Character', 'A character in a film')>>
interface Character {
<<GraphQL\Field('Name', 'Name of the character')>>
public function getName(): string;
}
<<GraphQL\ObjectType('Wizard', 'A wizard')>>
final class Wizard implements Character {
public function getName(): string {
return 'Gandalf';
}
}
In the above example, Wizard
is a GraphQL object which implement the Character
interface. This allows for queries like:
{
allCharacters {
__typename
name
}
}
In the above schema, this query might output a response like:
shape(
'data' => dict[
'allCharacters' => vec[
dict[
'__typename' => 'Wizard',
'name' => 'Gandalf',
],
],
],
'errors' => vec[],
);
This attribute annotates an enumeration to declare a GraphQL enum type:
<<GraphQL\EnumType('Colors', 'Available colors')>>
enum Colors: string {
RED = 'RED';
BLUE = 'BLUE';
GREEN = 'GREEN';
}
This attribute annotates a GraphQL shape as input to a GraphQL field:
<<GraphQL\InputObjectType('CreateUserInput', 'input args for creating a new user')>>
type CreateUserInput = shape(
'name' => string,
'age' => int,
'email' => string,
);
You can then use this input object as an argument:
final class MutationFields {
<<GraphQL\MutationRootField('CreateUser', 'Create a new user')>>
public static async function createUser(CreateUserInput $input): Awaitable<User> {
return await User::create($input['name'], $input['age'], $input['email']);
}
}
In GraphQL, fields which fail to be resolved can either be marked null in the response, or can "bubble-up" and cause their parent fields to be marked null as well. By default, Hack-GraphQL implements the first behavior: any field which throws an error during resolution will simply be null in the response. If the field throws an instance of GraphQL\UserFacingError
, then an error will be added to the list of errors
in the response from the resolver.
For example, say I have a schema like the following:
type Query {
user(name: String!): User
}
type User {
name: String
age: Int
}
Now, say that the resolver for the name
field on User
throws an error. Only the name
field will be nulled out, and I will still get data for the age
field.
To change this behavior, I can annotate the name
field with the KillsParentOnException
attribute:
<<GraphQL\Field('name', 'Name of the user'), GraphQL\KillsParentOnException>>
public function getName(): string {
throw new GraphQL\UserFacingError("Invalid!");
}
Now the entire User
object will be null in the response.
We welcome contributions to Hack-GraphQL. We do much of our discussion in #hack-graphql channel in the Hacklang Slack group; for an invite, fill out this form.