tag

Friday, 15 April 2022

a year ago

6 minutes read

  • Laravel
  • GraphQL
  • Testing
  • Backend
  • PHP

Laragraph - Articles for Everyone - Part 2

In the previous article, we started a new project called Laragraph and, at this point, we already have our main tools installed and configured plus our login system ready and tested šŸ˜Ž For this article, we will explore other things related to the main CRUD of the application for blog posts, shall we?

Tiago Sousa

Tiago Sousa

Software Engineer

Introductionlink

Laragraph is a really simple project that I created just to have the opportunity to explore a couple of tools that I was curious about and which I wanted to give it a try.

There are 3 certainties in life: death, taxes and the fact that you create a blog post application every time that you want to try a new framework/tool šŸ˜‚

Based on one of these 3 certainties in life, in this part 2 of building Laragraph we will handle a lot of topics like Migrations, Seeds, Factories, Policies, Validation and of course, Testing. I will keep the same structure as the previous article, a step-by-step with code examples that you can copy and test for yourself.

Articles? But I want them all So I get them alllink

In order to have articles, we need to create a couple of resources before, like the Model, the Migration, the Factory and, since we are working with GraphQL, we will need a Mutation.

We can take leverage of artisan to create these resources like below:

sail artisan make:model Article -mf
copy

By running this command, laravel will create the Article model, migration and factory for you! It's all about development experience šŸš€ The first thing that we will do is to update the schema for the articles table and define the foreign key for the user_id. The migration file should be under the database/migrations/ folder.

Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
$table->string('title');
$table->text('body');
$table->timestamps();
$table->foreignIdFor(User::class)
->constrained()
->cascadeOnDelete()
->cascadeOnUpdate();
});
copy

After configuring the migration for the articles, let's add the fillable columns and define the Eloquent Relationship for the article model under our app/Models/Article.php file.

protected $fillable = ['title', 'body', 'slug'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
copy

We also need to define the inverse Eloquent Relationship for the User. Since an article belongsTo a user, a user hasMany articles. So let's update our app/Models/User.php file:

public function articles(): HasMany
{
return $this->hasMany(Article::class);
}
copy

Now it's time to update our factory and our seeds so that every time that we want to refresh our database we have a clean environment and data. In your ArticleFactory.php update the definition() method and add the following keys to the return statement:

database/factories/ArticleFactory.php

public function definition()
{
return [
'user_id' => User::factory(),
'title' => $this->faker->sentence(),
'slug' => $this->faker->slug(),
'body' => $this->faker->paragraphs(6, true),
];
}
copy

Finally, and before running our migrations and our seeds, the only missing step is to update our database seeder file. When you are working on big projects, it makes sense to split the seeds by domain under the database/seeders folder and call those seeds in the DatabaseSeeder.php file but since this will be a small project we will call the factories directly from the DatabaseSeeder file like below:

public function run()
{
$user = User::factory()->create(['email' => '[email protected]']);
Article::factory(5)->for($user)->create();
}
copy

This piece of code above will ensure that every time that we run our seeds we will have access to a user with the email [email protected] that has already written 5 articles šŸ˜Ž What a writer! šŸ‘€

After all those changes, let's ensure that everything is working! For that, let's run our migration command and include the seeds:

sail artisan migrate:refresh --seed
copy

And BOOOM šŸ’£ 1 user and 5 articles in our database šŸ”„ Let's handle some GraphQL now!

To have a more clear overview of the project, I separated the schemas by domain. Since Lighthouse will only look for one schema file (which is schema.graphql), let's create a articles.graphql file at the same level as the schema.graphql and include this new schema file in our main schema.

In the graphql/schema.graphql add the following line and EOF:

#import articles.graphql
copy

and now let's define our Article Type, Mutation and Query at graphql/articles.graphql

type Article {
"Unique primary key."
id: ID!
"Unique slug address."
slug: String!
"Non-unique title."
title: String!
"Non-unique body."
body: String!
"The author of the article"
user: User! @belongsTo
"When the article was created."
created_at: DateTime!
"When the article was last updated."
updated_at: DateTime!
}
extend type Query {
"List multiple articles."
articles("Filters by title. Accepts SQL LIKE wildcards `%` and `_`." title: String @where(operator: "like")): [Article!]!
@paginate(defaultCount: 10)
}
copy

Since we already have a user that has written 5 articles in the database and we added a query to return those articles, why not just give it a try? Go to your GraphQL playground at http://localhost/graphql-playground and write the following query:

{
articles {
data {
slug
title
body
user {
name
email
}
}
paginatorInfo {
currentPage
firstItem
total
}
}
}
copy

Ouh! Wait, we can access the user data if we want? šŸ˜± But how? Well, when we created your Article Type we added a field called user with a @belongsTo directive that will return a User. This directive comes out of the box from Lighthouse and takes leverage from the Eloquent Relationship that you previously defined for the Article Model! Now with the power of GraphQL, we can decide which data we want to fetch and if you want to paginate them, of course šŸ˜Ž

Let me write!link

Now that we can fetch the articles and respective authors, we need to allow them to create some content but there are a couple of things that we need to ensure:

  • a user needs to be authenticated to create a new article
  • the slug for each blog post should be unique

As we already saw, GraphQL uses Queries and Mutations to perform operations and handle data flow. To allow our writers to share some content, we need to create a new Mutation: ArticleMutation. For that, let's use our lighthouse artisan command:

sail artisan lighthouse:mutation ArticleMutation
copy

This command will create an ArticleMutation.php under app/GraphQL/Mutations so that we can add our logic. I like to see the Mutations as if they were controllers, so let's create a store method to create a new article.

public function store($_, array $request)
{
return request()->user()->articles()->create($request);
}
copy

Do you see something wrong with the method above? Yeah, it creates the article but it doesn't verify if the user is authenticated and doesn't validate the data that is being stored in the database. That's because Lighthouse handles these kinds of things with directives at the schema level.

To do so, let's add the CreateArticleInput Input and createArticle mutation to your graphql/articles.graphql:

input CreateArticleInput @validator {
slug: String!
title: String!
body: String!
}
extend type Mutation {
createArticle(input: CreateArticleInput @spread): Article! @guard @field(resolver: "App\\GraphQL\\Mutations\\ArticleMutation@store")
}
copy

To solve the authentication problem we just need to add the @guard directive (which once again comes out of the box with Lighthouse) in the mutation. This directive will ensure that the user that is making the request is authenticated. This solves authentication but it doesn't solve validation!

Could you imagine if we were able to use all the validation rules that laravel gives us out of the box with a GraphQL API? It would be pretty nice, right? Well, it's possible! šŸš€ You can use the rules directly in the schema but I prefer to have the validation logic separated from the schema with a custom class to handle the validation rules and logic. That's exactly what the @validator directive does! With this directive, Lighthouse will search for a CreateArticleInputValidator under app/GraphQL/Validators folder. Since we don't have one, let's check if we can create a validation class with Lighthouse artisan command... and guess what? yeah, we have a command for that šŸ˜Ž

sail artisan lighthouse:validator CreateArticleInputValidator
copy

Within this file, you can use every single validation rule that laravel offers out of the box! Let's add a couple of rules to validate our create article mutation!

public function rules(): array
{
return [
'slug' => ['required', Rule::unique('articles', 'slug')],
'title' => ['required'],
'body' => ['required'],
];
}
copy

At this point, our users should be able to publish their articles! Let's try to create one ourselves? Let's use our GraphQL playground and run the following mutation:

mutation {
createArticle(input: { slug: "laragraph-articles-for-everyone", title: "title", body: "My awesome content šŸ˜Ž" }) {
title
slug
}
}
copy

NOTE:

Don't forget to add the Authorization header with the Bearer token that you retrieve from the login mutation due to the @guard.

If we did everything right, we should have a new record in our database for the articles table! šŸ¤—

OOPS, I made a typo in the title!link

It's nice to allow users to write their articles but what if they need to make changes? We need to support that feature! The process should be very similar to the create article process:

  • ensure authentication
  • ensure that the user that is editing the article it's the owner
  • validate the request

To allow users to edit their articles, let's start by updating our GraphQL schema for the articles and add the Input and the Mutation:

input UpdateArticleInput @validator {
id: ID!
slug: String
title: String
body: String
}
extend type Mutation {
...
updateArticle(input: UpdateArticleInput @spread): Article!
@guard
@can(ability: "update", model: "App\\Models\\Article", find: "id")
@field(resolver: "App\\GraphQL\\Mutations\\ArticleMutation@update")
}
copy

Did you notice something different from the createArticle mutation? Exactly, besides the @guard and @field directives, we also added a new one, the @can directive. This directive comes, again, out of the box with Lighthouse and abstracts the logic to use Laravel default built-in Policies. Policies are a way to validate if the user that is performing the action has permission to fulfill the operation. In our case, we need to ensure that the user that is updating the article is the person that actually wrote it!

Let's use Laravel built-in artisan command to create the policy for our article model:

sail artisan make:policy ArticlePolicy --model=Article
copy

This command will create a new file at app/Policies/ArticlePolicy.php with a couple of methods pre-defined. For now, we just need to add logic to the update method as below:

public function update(User $user, Article $article): bool
{
return $user->is($article->user);
}
copy

By adding the return statement above, we are ensuring what we need! How clean hein? šŸ¤©

Before updating the typo that we did in the title of the article, we need to create our UpdateArticleInputValidator class to validate the request!

sail artisan lighthouse:validator UpdateArticleInputValidator
copy

and add some basic rules:

public function rules(): array
{
return [
'id' => ['required'],
'slug' => ['sometimes', Rule::unique('articles', 'slug')->ignore($this->arg('id'), 'id')],
'title' => ['sometimes'],
'body' => ['sometimes'],
];
}
copy

Even with everything that we did so far, this feature is still not ready! Can you guess what's missing? Exactly, adding the logic for our mutation! Under our app/GraphQL/Mutations/ArticleMutation.php let's add the following for method:

public function update($_, array $request)
{
$article = Article::findOrFail($request['id']);
$article->update($request);
return $article;
}
copy

At this point, we are able to go to our GraphQL playground and update the post that we created with the following mutation

mutation {
updateArticle(input: { id: 6, title: "#2 - Laragraph - Articles for Everyone" }) {
slug
title
body
}
}
copy

And our record was updated successfully šŸ˜Ž

Can I also delete it? Please šŸ™link

Deleting an article it's the last operation that we will allow our users to do under the article model!

  • For our schema, let's add the following mutation under graphql/articles.graphql:
extend type Mutation {
...
deleteArticle(id: ID!): Article!
@guard
@can(ability: "delete", model: "App\\Models\\Article", find: "id")
@field(resolver: "App\\GraphQL\\Mutations\\ArticleMutation@destroy")
}
copy
  • Update our ArticlePolicy:
public function delete(User $user, Article $article): bool
{
return $user->is($article->user);
}
copy
  • And add the logic to our mutation at app/GraphQL/Mutations/ArticleMutation.php:
public function destroy($_, array $args)
{
$article = Article::findOrFail($args['id']);
$article->delete();
return $article;
}
copy

And with these 3 changes we have the delete feature ready šŸ˜Ž Let's run the mutation!

mutation {
deleteArticle(id: 6) {
title
}
}
copy

Do I deserve some tests? šŸ„ŗlink

To be sure that our features are working as expected, let's write some pest tests as we did for the previous article, shall we? šŸš€ Let's start by creating our test file with artisan:

sail artisan pest:test ArticleTest
copy
  • Can a user create an article? šŸ¤”
it('can create an article', function () {
Sanctum::actingAs(User::factory()->create());
$article = Article::factory()->make();
$this->assertDatabaseCount('articles', 0);
$this->graphQL(
/** @lang GraphQL */
'
mutation ($slug: String!, $title: String!, $body: String!) {
createArticle(input: { slug: $slug, title: $title, body: $body }) {
title
slug
}
}',
[
'slug' => $article->slug,
'title' => $article->title,
'body' => $article->body,
]
)->assertJson([
'data' => [
'createArticle' => [
'title' => $article->title,
'slug' => $article->slug,
],
],
]);
$this->assertDatabaseHas('articles', [
'title' => $article->title,
]);
});
copy
  • Can a user edit an article if he's not authenticated? šŸ¤”
it('cannot create an article if not authenticated', function () {
$article = Article::factory()->make();
$this->assertDatabaseCount('articles', 0);
$this->graphQL(
/** @lang GraphQL */
'
mutation ($slug: String!, $title: String!, $body: String!) {
createArticle(input: { slug: $slug, title: $title, body: $body }) {
title
slug
}
}',
[
'slug' => $article->slug,
'title' => $article->title,
'body' => $article->body,
]
)->assertGraphQLErrorMessage('Unauthenticated.');
$this->assertDatabaseCount('articles', 0);
});
copy
  • Can a user edit an article? šŸ¤”
it('can edit an article', function () {
Sanctum::actingAs($user = User::factory()->create());
$existentArticle = Article::factory()->for($user)->create();
$newArticle = Article::factory()->make();
$this->assertModelExists($existentArticle);
$this->graphQL(
/** @lang GraphQL */
'
mutation ($id: ID!, $title: String!) {
updateArticle(input: { id: $id, title: $title }) {
title
slug
}
}',
[
'id' => $existentArticle->id,
'title' => $newArticle->title,
]
)->assertJson([
'data' => [
'updateArticle' => [
'title' => $newArticle->title,
'slug' => $existentArticle->slug,
],
],
]);
$this->assertDatabaseCount('articles', 1);
$this->assertDatabaseHas('articles', [
'title' => $newArticle->title,
]);
});
copy
  • Can a user edit an article that he didn't create? šŸ¤”
it('cannot edit an article that doesnt belong to the user', function () {
Sanctum::actingAs(User::factory()->create());
$existentArticle = Article::factory()->create();
$newArticle = Article::factory()->make();
$this->assertModelExists($existentArticle);
$this->graphQL(
/** @lang GraphQL */
'
mutation ($id: ID!, $title: String!) {
updateArticle(input: { id: $id, title: $title }) {
title
slug
}
}',
[
'id' => $existentArticle->id,
'title' => $newArticle->title,
]
)->assertGraphQLErrorMessage('This action is unauthorized.');
});
copy
  • Can a user delete an article? šŸ¤”
it('can delete an article', function () {
Sanctum::actingAs($user = User::factory()->create());
$article = Article::factory()->for($user)->create();
$this->assertModelExists($article);
$this->graphQL(
/** @lang GraphQL */
'
mutation ($id: ID!) {
deleteArticle(id: $id) {
title
}
}
',
['id' => $article->id]
)->assertJson([
'data' => [
'deleteArticle' => [
'title' => $article->title,
],
],
]);
$this->assertModelMissing($article);
});
copy
  • Can a user delete an article that he didn't create? šŸ¤”
it('cannot delete an article that doesnt belong to the user', function () {
Sanctum::actingAs(User::factory()->create());
$article = Article::factory()->create();
$this->assertModelExists($article);
$this->graphQL(
/** @lang GraphQL */
'
mutation ($id: ID!) {
deleteArticle(id: $id) {
title
}
}
',
['id' => $article->id]
)->assertGraphQLErrorMessage('This action is unauthorized.');
$this->assertModelExists($article);
});
copy

After writing all these tests let's run them and see the output!

sail artisan test
copy

Running Tests

Conclusionslink

This was the second part of a series that I will be writing while I explore these tools. I hope that this article can help someone in the future that is trying to develop a GraphQL API with Laravel!

For each article I will attach a PR so you can view all the changes! For this one, feel free to check it here.

I may release the third part regarding file uploads, what do you think?! Feel free to drop me some feedback on twitter! šŸ™

If you found this article interesting, feel free to share it with your colleagues or friends, because you know... Sharing is caring!

Also, if you enjoy working at a large scale in projects with global impact and if you enjoy a challenge, please reach out to us at xgeeks! We're always looking for talented people to join our team šŸ™Œ

Tiago Sousa

Written by Tiago Sousa

Hey there, my name is Tiago Sousa and I'm a Fullstack Engineer currently working at xgeeks. I'm a technology enthusiast and I try to explore new things to keep myself always updated. My motto is definitely "Sharing is caring" and that's exactly why you are currently reading this!

Interested in collaborating with me?

Let's Talk
footer

All the rights reserved Ā© Tiago Sousa 2022

memoji12

Designed with love by Mauricio Oliveira