Thinking Through Features In Laravel: Note Sharing

I’ve garnered some inspiration by this series by DHH*, “On Writing Software Well.” I thought it would be interesting to go through this process in a Laravel context.

In our case, I’ve installed a fresh Laravel app and we will explore the concept of building some features for a Note sharing API. Not a real-life project, though in future posts in this series, I plan on using real project examples, but this should do the trick.

Concept

For this example, I want to only focus on the “sharing” feature of the app. I’m not particularly concerned about the creation of notes or how the notes will be displayed at the moment. The main focus will be:

  • A user can share their note with another user.
  • The original owner of the note will remain the owner, so they can revoke access that note.
  • If a user share’s a note with another user, the shared user will be able to view that note.

There are constraints I’d like to point out. I want the owner of a note to remain the owner. Eventually, they will be able to give permissions to their note shares. I originally thought of making a copy of note when it is shared, but I thought it would be better for the user to have more control over their notes.

Implementation

Laravel ships with a migration for the users table, so I made two migrations for the two tables I’m creating for this feature. One table to hold our notes and another table to hold shared notes.

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateNotesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('notes', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('title');
            $table->text('contents');
            $table->boolean('private')->default(true);

            $table->unsignedBigInteger('user_id');

            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('notes');
    }
}
 <?php
    
    use Illuminate\Support\Facades\Schema;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Database\Migrations\Migration;
    
    class CreateSharedNotesTable extends Migration
    {
        /**
         * Run the migrations.
         *
         * @return void
         */
        public function up()
        {
            Schema::create('shared_notes', function (Blueprint $table) {
                $table->bigIncrements('id');
                $table->unsignedBigInteger('note_id');
                $table->unsignedBigInteger('owner_id');
                $table->unsignedBigInteger('target_id');
                $table->timestamps();
            });
        }
    
        /**
         * Reverse the migrations.
         *
         * @return void
         */
        public function down()
        {
            Schema::dropIfExists('shared_notes');
        }
    }

I have a one-to-many relationship with users and notes. A user can have many notes and a note belongs to a user.

User.php —User model

public function notes()
        {
            return $this->hasMany('App\Note');
        }

Note.php — Note model

public function user() 
        {
            return $this->belongsTo('App\User');
        }

At this point, I’m not really sure relationships are necessary for the SharedNote model. I’m sure I’ll need one in the near future, but I just want a place to store records of shared notes and to reference who shared notes with who. For our example, I think I’ll end up using it to confirm that a user can view another user’s note if it is shared with them.

Moving on, we have an endpoint for sharing a note with another user in routes/api.php.

 Route::post('/notes/{id}/share', 'NotesController@share');

This route will use our share method in our NotesController. In that share method in our controller, it takes a share_with value in our request body, which will be the user id of the person that note will be shared with. We look up the note based on the id wildcard in the our API endpoint, and then create a SharedNote record based on the note details. Then return the shared note results via JSON in the response.

<?php    
public function share(Request $request, $id) {
    
            $request->validate([
                'share_with' => 'required'
            ]);
    
            $originalNote = Note::findOrFail($id);
    
            $sharedNote = SharedNote::create([
                'note_id' => $originalNote->id,
                'owner_id' => $originalNote->user_id,
                'target_id' => $request->get('share_with') 
            ]);
    
            return response()->json([
                'details' => $sharedNote
            ]);
        }

Since writing tests ensures our confidence with our codebase, let’s write a feature test and test our endpoint.

<?php
    
    namespace Tests\Feature;
    
    use App\User;
    use App\Note;
    use Tests\TestCase;
    use Illuminate\Foundation\Testing\WithFaker;
    use Illuminate\Foundation\Testing\RefreshDatabase;
    
    class NoteSharingTest extends TestCase
    {
        use RefreshDatabase;
    
        /**  @test */
        public function a_user_can_share_a_note_with_another_user()
        {
            $alice = factory(User::class)->create();
            $bob = factory(User::class)->create();
    
            $aliceNote = factory(Note::class)->create([
                'user_id' => $alice->id
            ]);
    
            $response = $this->withHeaders([
                'Authorization' => 'Bearer '. $alice->api_token
            ])->json('POST', '/api/notes/' . $aliceNote->id.  '/share', [
                'share_with' => $bob->id
            ]);
    
            $response
                ->assertStatus(200)
                ->assertJson([
                    'details' => [
                        'note_id' => $aliceNote->id,
                        'owner_id' => $alice->id,
                        'target_id' => $bob->id   
                    ]
                ]);
        }
    }

Given we have two users and note record owned by Alice. When we make a POST request to the share endpoint with bob’s id as the share_with value in the request body. Then we should assert that the correct JSON is displayed within details and we should have a 200 OK response.

Just a note, I created a model factory for notes. Model factories are super useful for generating data on the fly. Especially useful when writing tests. You can read more about them in the Laravel documentation:Database Testing - Laravel - The PHP Framework For Web Artisans

Moving Forward

We can now share a note with another user. Our test is running green and everything is fine and dandy. However, there are still improvements I would like to address. What if a user shares a note with the same user more than once? How will we provide that check? If an owner of a note revokes a share from user, how should we handle that? Should we just remove the record from the shared_notes table?

Also, we still have to address how the target user will be able to view an owner’s note while blocking non-shared users from viewing that note. I don’t want to have to handle that logic in our controller.

Thinking through our check, we could create service class that has provides a method for our check then we can store our shared note if the check is valid. Then we can use the create function in our NotesController. The purpose of this is to have our methods be testable (perhaps in a unit test in this case) and so we don’t have our logic muddying up our controller.

In my opinion, I feel like the only responsibilities of the controller should be:

  • Accepting a request.
  • Handle that request.
  • Return a proper response.

I usually tend to apply these rules, even early in development of a project, so I can keep things clean and testable. Can you put logic in the controller if the solution is dead simple? Sure! I still do that from time-to-time. Nothing wrong with that. Though, I try to realize the main purpose of the controller layer from the beginning.

Implementing the Service Class

Thinking through, I could have a single action class that handles the share note functionality. Maybe it’s called ShareNote that lives inside a services directory. Then, I can have a method that handles the check then use that method in a share() method.

In our ShareNote class, I figured we can start by creating a hasBeenShared method that checks if a note has already been shared to the intended user.

<?php
  public function hasBeenShared(int $noteId, int $targetId) : bool
        {
            $note = SharedNote::where('note_id', $noteId)
                ->where('target_id', $targetId)
                ->first();
    
            if ($note) {
                return true;
            }
    
            return false;
        }

Then, we can create a share method that calls the hasBeenShared method. If hasBeenShared returns false, all is good and we can grab the owner’s note and store a SharedNote record. If true, we return null.

Then I wrote a couple of unit tests for our hasBeenShared method to ensure our confidence that this method is working as intended.

<?php
    
    namespace Tests\Unit;
    
    use App\User;
    use App\Note;
    use App\SharedNote;
    use App\Services\ShareNote;
    use Tests\TestCase;
    use Illuminate\Foundation\Testing\WithFaker;
    use Illuminate\Foundation\Testing\RefreshDatabase;
    
    class ShareNoteTest extends TestCase
    {
    
        use RefreshDatabase;
    
        /** @test */
        public function note_has_already_been_shared() 
        {
            $alice = factory(User::class)->create();
            $bob = factory(User::class)->create();
    
            $aliceNote = factory(Note::class)->create([
                'user_id' => $alice->id
            ]);
    
    
            $sharedNote = factory(SharedNote::class)->create([
                'note_id' => $aliceNote->id,
                'owner_id' => $aliceNote->user_id,
                'target_id' => $bob->id
            ]);
    
            $note = new ShareNote;
    
            $hasBeenShared = $note->hasBeenShared($aliceNote->id, $bob->id);
    
            $this->assertTrue($hasBeenShared);
        }
    
    
        /** @test */
        public function note_has_not_been_shared()
        {
            $alice = factory(User::class)->create();
            $bob = factory(User::class)->create();
    
            $aliceNote = factory(Note::class)->create([
                'user_id' => $alice->id
            ]);
    
            $note = new ShareNote;
    
            $hasBeenShared = $note->hasBeenShared($aliceNote->id, $bob->id);
    
            $this->assertFalse($hasBeenShared);
        }
    
    
    }

These tests both provide assertions for two obvious use cases: if a note has already been shared and if a note has not been shared with the intended user. We run and we get green! Neat!

Next, in our NotesController, I injected our ShareNote service into the constructor.

public function __construct(ShareNote $note) 
        {
            $this->note = $note;
        }

Then we can use our share method like so.

<?php
public function share(Request $request, $id) {
    
            $request->validate([
                'share_with' => 'required'
            ]);
    
            $sharedNote = $this->note->share($id, $request->get('share_with'));
    
            if (!$sharedNote) {
                return response()->json([
                    'message' => 'Note already shared with user.'
                ], 400);
            }
    
            return response()->json([
                'details' => $sharedNote
            ]);
        }

Much cleaner and we have made our logic testable. I decided to provide a check, if our share method returns null, then we can return the appropriate JSON response with the correct HTTP status code.

Awesome, but what about testing our share method? Should we unit test that? We could, but I figured we can let the feature test we wrote earlier handle that for us. After implementing the share method into our controller, if we run all tests, they still run green. I didn’t think it would be necessary to provide a unit test for it; at least for the moment.

Viewing a Shared Note

For viewing a shared endpoint, I’m thinking this should be it’s own endpoint, separate from showing a single note.

Route::get('/notes/{id}/view', 'NotesController@viewSharedNote');

For our check method, it is going to be very similar to our hasBeenShared method. I decided to add this method into our ShareNote class. It didn’t make much sense to create a new class just for this check.

public function canViewSharedNote(int $noteId, int $userId) : bool 
        {
            $note = SharedNote::where('note_id', $noteId)
                ->where('target_id', $userId)
                ->first();
    
            if (!$note) {
                return false;
            }
    
            return true;
        }

Provided some unit tests for our method.

/** @test */
        public function user_can_view_shared_note() 
        {
            $alice = factory(User::class)->create();
            $bob = factory(User::class)->create();
    
            $aliceNote = factory(Note::class)->create([
                'user_id' => $alice->id
            ]);
    
    
            $sharedNote = factory(SharedNote::class)->create([
                'note_id' => $aliceNote->id,
                'owner_id' => $aliceNote->user_id,
                'target_id' => $bob->id
            ]);
    
            $note = new ShareNote;
    
            $canViewNote = $note->canViewSharedNote($aliceNote->id, $bob->id);
    
            $this->assertTrue($canViewNote); 
        }
    
        /** @test */
        public function user_cannot_view_shared_note()
        {
            $alice = factory(User::class)->create();
            $bob = factory(User::class)->create();
    
            $aliceNote = factory(Note::class)->create([
                'user_id' => $alice->id
            ]);
    
            $note = new ShareNote;
    
            $canViewNote = $note->canViewSharedNote($aliceNote->id, $bob->id);
    
            $this->assertFalse($canViewNote);
        }

Now let’s implement it into our controller.

public function viewSharedNote(Request $request, $id) 
        {
             // getting user from the request since this is an API
            $user = $request->user(); 
    
            $viewable = $this->note->canViewSharedNote($id, $user->id);
    
            if ($viewable) {
                $note = Note::findOrFail($id);
    
                return response()->json($note);
            }
    
            return response()->json([
                'message' => 'Unauthorized access'  
            ], 401);
        }

Wrote a feature test for this endpoint to make sure the endpoint is functioning as intended.

<?php
/** @test */
        public function a_user_can_view_shared_note()
        {
            $alice = factory(User::class)->create();
            $bob = factory(User::class)->create();
    
            $aliceNote = factory(Note::class)->create([
                'user_id' => $alice->id
            ]);
    
            $sharedNote = factory(SharedNote::class)->create([
                'note_id' => $aliceNote->id,
                'owner_id' => $aliceNote->user_id,
                'target_id' => $bob->id
            ]);
    
            $response = $this->withHeaders([
                'Authorization' => 'Bearer '. $bob->api_token
            ])->json('GET', '/api/notes/' . $aliceNote->id . '/view');
    
            $response
                ->assertStatus(200)
                ->assertJson([
                    'title' => $aliceNote->title,
                    'contents' => $aliceNote->contents
                ]);
    
        }
    
        /** @test */
        public function a_user_cannot_view_shared_note()
        {
            $alice = factory(User::class)->create();
            $bob = factory(User::class)->create();
    
            $aliceNote = factory(Note::class)->create([
                'user_id' => $alice->id
            ]);
    
            $response = $this->withHeaders([
                'Authorization' => 'Bearer '. $bob->api_token
            ])->json('GET', '/api/notes/' . $aliceNote->id . '/view');
    
            $response
                ->assertStatus(401)
                ->assertJson([
                    'message' => 'Unauthorized access'
                ]);
    
        }

A Few Things to Consider

Neat! All is well and tests are passing! A few things to mention though. There’s starting to become some duplication in our tests. We can probably tackle this by using a setUp method and set up our world before we run our tests. That way we can use our data for each test.

Also, we can probably write our own middleware to check if a note is viewable by another user. This would clean up our controller even more since we would have that check in our middleware instead.

<?php
    namespace App\Http\Middleware;
    
    use Closure;
    
    class ViewableNote
    {
        /**
         * Handle an incoming request.
         *
         * @param  \Illuminate\Http\Request  $request
         * @param  \Closure  $next
         * @return mixed
         */
        public function handle($request, Closure $next)
        {
              // call our method from our service here
              // and handle request. 
    
            return $next($request);
        }
    }

Then we can pass our middleware into our route like so:

<?php
Route::get('/notes/{id}/view', 'NotesController@viewSharedNote')->middleware(ViewableNote::class);

You can read more on middleware in Laravel here: Middleware - Laravel - The PHP Framework For Web Artisans

Closing Points

We talked more about high-level concepts in this post. I tend to like thinking through features like this and explore different ways we can achieve the same result. Furthermore, we can build better software by building the API we want; as a result, a better system for your users. I’d like to think your own codebase is an API for yourself and your team.

Cheers!

[*] - DHH is an acronym for David Heinemeier Hansson who is most notable for creating the web framework, Ruby on Rails.

Featured TechBixly Inc