Workshop GraphQL with Symfony


query workshopGraphQLSF {
	person(id: "renatomefi") {
		name
		twitter
        company
		bioPerLine
	}
	talk(id: "graphql-php-symfony") {
	    title
	    date
	    type
	}
}
                    

{
  "data": {
    "person": {
      "name": "Renato Mendes Figueiredo",
      "twitter": "@renatomefi",
      "company": "@usabilla",
      "bioPerLine": [
        "ZCE, ZCPE, ZFCE, LPIC-1, LFCS, LFCE Professional",
        "a.k.a.: A guy who loves linux and software dev!",
        "Co-organizer of @PHPAmersfoort and @rumoazcephp",
        "Maintainer of php-vcr and OverblogGraphQLBundle"
      ]
    },
    "talk": {
      "title": "Workshop GraphQL in Symfony",
      "date": "2018-06-07T13:45:00.042Z",
      "type": "DPC 2018"
    }
  }
}
                    

Open me!

N+1 issue


GET /people?limit=10&start=0

GET /person/luke

GET /planet/tatooine
GET /person/luke/starshipConnection?page=1&limit=2

GET /starship/X-wing
GET /starship/ImperialShuttle
				
“GraphQL prevents HTTP overfetching”

Tailor made endpoint (RPC)

Query

Result


query lukeSkywalker {
	person(id: "cGVvcGxlOjE=") {
		name
		birthYear
		height
		hairColor
		starshipConnection {
			starships {
				name
			}
		}
		homeworld {
			name
		}
	}
}
							

 {
   "data": {
     "person": {
       "name": "Luke Skywalker",
       "birthYear": "19BBY",
       "height": 172,
       "hairColor": "blond",
       "starshipConnection": {
         "starships": [
           {
             "name": "X-wing"
           },
           {
             "name": "Imperial shuttle"
           }
         ]
       },
       "homeworld": {
         "name": "Tatooine"
       }
     }
   }
 }
							

Clients ♥ Backend API

Query

Result


query theGoodMovies {
    allFilms(first: 3) {
       films {
           episodeID
           title
       }
    }
}
                

{
  "data": {
    "allFilms": {
      "films": [
         {
           "episodeID": 4,
           "title": "A New Hope"
         },
         {
           "episodeID": 5,
           "title": "The Empire Strikes Back"
         },
         {
           "episodeID": 6,
           "title": "Return of the Jedi"
         }
      ]
    }
  }
}
                

    composer create-project "symfony/skeleton:^4.1" graphql-workshop
                

    composer require server --dev
                

    ./app/console server:run
                

    php -dxdebug.remote_enable=1 -dxdebug.remote_mode=req -dxdebug.remote_port=9000 -dxdebug.remote_host=127.0.0.1 -S 127.0.0.1:8080 ./public/index.php
                

    composer req "overblog/graphql-bundle:0.11"
                

Declare the base Query type


# config/graphql/types/Query.yaml
Query:
    type: object
    config:
        description: "Main Queries"
        fields:
            hello:
                type: "String"
                resolve: "World"
                

    composer req "overblog/graphiql-bundle" --dev
                

    open http://127.0.0.1:8000/graphiql
                

    query {
        hello
    }
                

Args in a Query


    # config/graphql/types/Query.yaml
    Query:
        type: object
        config:
            description: "Main Queries"
            fields:
                hello:
                    type: "String"
                    resolve: "World"
                helloFromInput:
                    type: "String"
                    args:
                        name:
                            type: "String"
                            resolve: "@=args['name']"
                            resolve: "@=args['name'] ?: 'Nothing'"
                            resolve: "@=args['name'] ?: 'Guest'"
                    

My first Mutation


    # config/graphql/types/Mutation.yaml
    Mutation:
        type: object
        config:
            description: "Main Mutations"
            fields:
                sayHello:
                    type: "String"
                    args:
                        hello:
                            type: "String!"
                    resolve: "@='your input is: ' ~ args['hello']"
                

Input Object


    # config/graphql/types/Mutation.yaml
    ...
            fields:
                createWorkshop:
                    type: "String"
                    args:
                        workshop: "WorkshopInput!"
                    resolve: "@=args['workshop']['conference']~':'~args['workshop']['name']"

    ...
    WorkshopInput:
        type: input-object
        config:
            fields:
                conference:
                    type: "String!"
                name:
                    type: "String!"                    
                

Workshop VO


    # src/Domain/Workshop.php

    namespace App\Domain;
    
    final class Workshop
    {
        /**
        * @var string
        */
        private $conference;
    
        /**
        * @var string
        */
        private $name;
    
        public function __construct(string $name, string $conference)
        {
            $this->name = $name;
            $this->conference = $conference;
        }
    
        public function getConference(): string
        {
            return $this->conference;
        }
    
        public function getName(): string
        {
            return $this->name;
        }
    }
                                 
                

    # config/graphql/types/Mutation.yaml
    WorkshopInput:
    ...
    Workshop:
        type: object
        config:
            fields:
                conference:
                    type: "String!"
                name:
                    type: "String!" 
                    

Mutation Class


    # src/Infrastructure/GraphQL/Workshop/Mutation/Create.php

    namespace App\Infrastructure\GraphQL\Workshop\Mutation;
    
    use App\Domain\Workshop;
    
    final class Create
    {
        public function __invoke(array $arguments): Workshop
        {
            return new Workshop(
                $arguments['name'],
                $arguments['conference']
            );
        }
    }
    
                

    # config/graphql/types/Mutation.yaml
    ...
    createWorkshop:
        type: "Workshop"
        args:
            workshop: "WorkshopInput!"
        resolve: '@=mutation("App\\Infrastructure\\GraphQL\\Workshop\\Mutation\\Create", [args["workshop"]])'
                

    mutation {
        createWorkshop(workshop: {conference: "DPC", name: "GraphQL"}) {
            conference
            name
        }
    }
                

    {
        "errors": [
            {
            "debugMessage": "Unknown mutation with alias \"App\\Infrastructure\\GraphQL\\Workshop\\Mutation\\Create\" (verified service tag)",
            "message": "Internal server Error",
            "category": "internal",
            "locations": [
                {
                "line": 2,
                "column": 3
                }
            ],
            "path": [
                "createWorkshop"
            ]
    }
                

Fix it


    # config/services.yaml
    ...
    services:
        ...
        App\Infrastructure\GraphQL\Workshop\Mutation\Create:
            tags: ['overblog_graphql.mutation']
                

yaaay, it works

Improve it

Using new SF DI way


    # config/services.yaml
    ...
    services:
        ...
        _instanceof:
            Overblog\GraphQLBundle\Definition\Resolver\MutationInterface:
                tags: ['overblog_graphql.mutation']
            Overblog\GraphQLBundle\Definition\Resolver\ResolverInterface:
                tags: ['overblog_graphql.resolver']
            GraphQL\Type\Definition\Type:
                tags: ['overblog_graphql.type']
                

    # src/Infrastructure/GraphQL/Workshop/Mutation/Create.php

    namespace App\Infrastructure\GraphQL\Workshop\Mutation;
    
    use App\Domain\Workshop;
    use Overblog\GraphQLBundle\Definition\Resolver\MutationInterface;

    final class Create implements MutationInterface
    ...
                        
                

Don't use auto mapping, it will be removed on v0.12

https://github.com/overblog/GraphQLBundle/pull/333

Organize your Mutation.yaml


    # config/graphql/types/Domain/Workshop.yaml                        
    Workshop:
        type: object
        config:
            fields:
                conference:
                    type: "String!"
                name:
                    type: "String!"

    WorkshopInput:
        type: input-object
        config:
            fields:
                conference:
                    type: "String!"
                name:
                    type: "String!"

                

Refactor it


    # config/graphql/types/Domain/Workshop.yaml   
    WorkshopPrototype:
        decorator: true
        config:
            fields:
                conference:
                    type: "String!"
                name:
                    type: "String!"

    Workshop:
        type: object
        inherits: [WorkshopPrototype]
        config:
            fields:
                id:
                    type: "String!"

    WorkshopInput:
        type: input-object
        inherits: [WorkshopPrototype]

                

    composer req ramsey/uuid
                

Implement getId() on Workshop

Custom type uuid


    # src/Infrastructure/GraphQL/UuidType.php

    namespace App\Infrastructure\GraphQL;
    
    use GraphQL\Language\AST\StringValueNode;
    use GraphQL\Type\Definition\ScalarType;
    use Overblog\GraphQLBundle\Definition\Resolver\AliasedInterface;
    use Ramsey\Uuid\Uuid;
    
    final class UuidType extends ScalarType implements AliasedInterface
    {
        private const NAME = 'UUID';
    
        public function __construct()
        {
            parent::__construct([
                'name' => self::NAME,
                'description' => 'A UUID represented as string'
            ]);
        }
    
        public function serialize($value): ?string
        {
            return \is_string($value) && Uuid::isValid($value) ? $value : null;
        }
    
        public function parseValue($value): ?string
        {
            return \is_string($value) && Uuid::isValid($value) ? $value : null;
        }
    
        public function parseLiteral($valueAST): ?string
        {
            if (!$valueAST instanceof StringValueNode) {
                return null;
            }
    
            return $this->parseValue($valueAST->value);
        }
    
        public static function getAliases(): array
        {
            return [self::NAME];
        }
    }
                                  
                

    # config/graphql/types/Domain/Workshop.yaml                        
    Workshop:
        type: object
        inherits: [WorkshopPrototype]
        config:
            fields:
                id:
                    type: "UUID!"
                
                

Resolvers


    # src\App\Infrastructure\GraphQL\Workshop\Resolver\Workshop
    
    namespace App\Infrastructure\GraphQL\Workshop\Resolver;
    
    use App\Domain\Workshop as WorkshopVo;
    use Overblog\GraphQLBundle\Definition\Resolver\AliasedInterface;
    use Overblog\GraphQLBundle\Definition\Resolver\ResolverInterface;
    
    final class Workshop implements ResolverInterface, AliasedInterface
    {
        public function allWorkshops(): array
        {
            return [
                new WorkshopVo('GraphQL with Symfony', 'DPC 2018', '5cf8c6ac-7f40-46a1-b666-2c262d4e8abe'),
                new WorkshopVo('GraphQL with Symfony', 'FWDays 2018', 'aab5088d-6b59-4e00-84b8-3e71943fd2a1'),
            ];
        }
        
        public function workshopById(string $id): ?WorkshopVo
        {
            return array_filter($this->allWorkshops(), function (WorkshopVo $workshop) use ($id) {
                return $workshop->getId() === $id;
            })[0] ?? null;
        }
        
        public static function getAliases(): array
        {
            return [
                'allWorkshops' => 'app.graphql.resolver.workshop.all',
                'workshopById' => 'app.graphql.resolver.workshop.by.id',
            ];
        }
    }
                    
                

    # config/graphql/types/Query.yaml   
    Query:
        inherits:
            - WorkshopQuery

    ...
    WorkshopQuery:
        decorator: true
        config:
            fields:
                WorkshopsDirectly:
                    type: "[Workshop]"
                    resolve: '@=resolver("App\\Infrastructure\\GraphQL\\Workshop\\Resolver\\Workshop::allWorkshops")'
                                        
                    

Now with Alises


    # config/graphql/types/Query.yaml
    ...
    WorkshopQuery:
        decorator: true
        config:
            fields:
                Workshops:
                    type: "[Workshop]"
                    resolve: '@=resolver("app.graphql.resolver.workshop.all")'
                Workshop:
                    type: "Workshop"
                    args:
                        id:
                            type: "UUID!"
                    resolve: '@=resolver("app.graphql.resolver.workshop.by.id", [args["id"]])'
                                    
                

Now with relay pagination


        # config/graphql/types/Query.yaml   
        ...
        WorkshopQuery:
            decorator: true
            config:
                fields:
                    WorkshopsRelay:
                        type: "WorkshopConnection"
                        argsBuilder: "Relay::Connection"
                        resolve: '@=resolver("app.graphql.resolver.workshop.relay", [args])'
                                        
                

    # src\App\Infrastructure\GraphQL\Workshop\Resolver\Workshop
                            
    ...

    public function relayWorkshops($args): Connection
    {
        $data = $this->allWorkshops();

        return ConnectionBuilder::connectionFromArraySlice(
            $data,
            $args,
            [
                'arrayLength' => \count($data),
            ]
        );
    }

    ...

    public static function getAliases(): array
    {
        return [
            ...
            'relayWorkshops' => 'app.graphql.resolver.workshop.relay',
        ];
    }

                

Add Person to the Schema

  • create Person Domain VO
  • types/Domain/Person.yaml
  • Person Resolver
  • Custom Scalar DateType on types/Scalar.yaml
  • Query for People and Person by id

Refactor Workshop and Person to use Repositories

Resolving relations


    # config/graphql/types/Domain/Workshop.yaml                        
    Workshop:
        type: object
        inherits: [WorkshopPrototype]
        config:
            fields:
                id:
                    type: "UUID!"
                enrolledPeople: <================
                    type: "[Person]"
                    description: "All People related to this Workshop"
                    resolve: '@=resolver("app.graphql.resolver.people.by.workshop", [value, args])'
                    
                

    # App\Infrastructure\GraphQL\Workshop\Resolver\WorkshopPeople
                            
    ...

    public const ENROLLMENT_TYPE_TUTOR = 'tutor';

    public const ENROLLMENT_TYPE_ATTENDEE = 'attendee';

    private static $data = [
        '5cf8c6ac-7f40-46a1-b666-2c262d4e8abe' => [
            '3317742c-1dec-43d1-b1eb-06634a58e95b' => self::ENROLLMENT_TYPE_TUTOR,
            'd186ebf4-9de1-4eb6-b5ab-fbc07f7ca1d6' => self::ENROLLMENT_TYPE_ATTENDEE,
        ],
        'aab5088d-6b59-4e00-84b8-3e71943fd2a1' => [
            '9099d144-fc0b-417c-a003-eb9396a3e264' => self::ENROLLMENT_TYPE_TUTOR,
            '3317742c-1dec-43d1-b1eb-06634a58e95b' => self::ENROLLMENT_TYPE_ATTENDEE,
        ]
    ];

    ...

                

    # App\Infrastructure\GraphQL\Workshop\Resolver\WorkshopPeople
                            
    ...

    /**
     * @return Person[]
     */
    public function resolvePeopleByWorkshop(Workshop $workshop, Argument $args): Generator
    {
        $peopleIds = self::$data[$workshop->getId()];

        foreach ($peopleIds as $personId => $role) {
             yield $this->personRepository->findById($personId);
        }
    }
    
    ...
    
                

    # config/graphql/types/Domain/Person.yaml                        
    Person:
        type: object
        config:
            fields:
                enrolledWorkshops:
                    type: "[Workshop]"
                    resolve: "@=resolver('app.graphql.resolver.workshop.by.people', [value])"
                    
                

    # App\Infrastructure\GraphQL\Workshop\Resolver\WorkshopPeople
                            
    ...

    public function resolveWorkshopsByPerson(Person $person): Generator
    {
        foreach (self::$data as $workshopId => $people) {
            if (array_key_exists($person->getId(), $people)) {
                yield $this->workshopRepository->findById($workshopId);
            }
        }
    }
    
    ...
    
                

Filter by enrollment type


    # config/graphql/types/Domain/Workshop.yaml                        
    Workshop:
        ...
                enrolledPeople:
                    type: "[Person]"
                    args:
                        enrollmentType:
                            type: WorkshopEnrollment
        ...

    WorkshopEnrollment:
        type: enum
        config:
            description: "Possible enrollment types between Workshop and Person"
            values:
                TUTOR:
                    value: '@=constant("App\\Infrastructure\\GraphQL\\Workshop\\Resolver\\WorkshopPeople::ENROLLMENT_TYPE_TUTOR")'
                ATTENDEE:
                    value: '@=constant("App\\Infrastructure\\GraphQL\\Workshop\\Resolver\\WorkshopPeople::ENROLLMENT_TYPE_ATTENDEE")'

                

Too much recursion?!?!


    # config/packages/graphql.yaml                      
    overblog_graphql:
        security:
            query_max_depth: 3
    
                

Fancier way


    # config/packages/graphql.yaml                      
    overblog_graphql:
        security:
            query_max_depth: 3
            query_max_complexity: 48
    
                

    # config/graphql/types/Domain/Person.yaml                     
    ...
    enrolledWorkshops:
        ...
        complexity: '@=2 + childrenComplexity'
    
                

Security/acl


    # config/graphql/types/Domain/Person.yaml                      
    Person:
        config:
            fields:
                ...
                bornDate:
                    type: "Date!"
                    access: '@=service("App\\Infrastructure\\GraphQL\\Workshop\\Resolver\\WorkshopPeople").canAccessBornDate(value)'
    
                

    # App\Infrastructure\GraphQL\Workshop\Resolver\WorkshopPeople
                            
    ...

    public function canAccessBornDate(Person $person): bool
    {
        return $person->getId() !== '3317742c-1dec-43d1-b1eb-06634a58e95b';
    }
    
    ...
    
                

    # config/graphql/types/Domain/Person.yaml                      
    Person:
        config:
            fields:
                ...
                bornDate:
                    type: "Date!"
                    public: '@=service("App\\Infrastructure\\GraphQL\\Workshop\\Resolver\\WorkshopPeople").canViewBornDate(typeName, fieldName)'
                    access: '@=service("App\\Infrastructure\\GraphQL\\Workshop\\Resolver\\WorkshopPeople").canAccessBornDate(value)'
    
                

    # App\Infrastructure\GraphQL\Workshop\Resolver\WorkshopPeople
                            
    ...

    public function canViewBornDate(string $typeName, string $fieldName): bool
    {
        return true;
    }
    
    ...
    
                

Free style

  • Integration Tests
  • Clean-up Symfony stuff: Controllers, twig, templates
  • Add a sqlite
  • Apollo React front-end

Useful links

Thank you! Any Questions?

http://talks.mefi.in/workshop-graphql-dpc18

Please leave feedback!

https://joind.in/talk/501d0