Blog header image

Rebuilding my invoicing application in Laravel, Part 4 - Endpoints

Posted on Dec 4th, 2017

## Goal In this post, I want to build out the backend functionality for the client sections. Sometimes, I will refer to clients as a "resource". Usually, but not always, resources align with the primary database tables of my application. So clients, invoices, work orders, etc will all be generalized as "resources". For this rebuild, I would like to use Vue components that leverage AJAX to handle GET, POST, PUT and DELETE operations for resources as needed. So I will start by building out a basic clients endpoint. ## Tests and Development I'll start with building a suite of http tests with endpoints to handle RESTful verbs for the client "resource". ### GET ```console php artisan make:test ClientsEndpointTests/GetClientsEndpointTest ``` Again, I'm keeping it very simple for now and will add tests as needed. Here are 2 basic tests for the GET endpoint. ```php class GetClientsEndpointTest extends TestCase { use RefreshDatabase; protected $base = 'api/client'; /** * @test */ public function guests_can_not_reach_get_clients_endpoint() { $this->json('GET', $this->base)->assertStatus(401); } /** * @test */ public function get_clients_endpoint_should_return_a_list_of_all_clients() { $clients = factory(Client::class, 2)->create(); $this->actingAsNewUser() ->json('GET', $this->base) ->assertExactJson($resources->toArray()); } } ``` Let's run the first test. ```console phpunit --filter guests_can_not_reach_get_clients_endpoint There was 1 failure: 1) Tests\Feature\ClientsEndpointTests\GetClientsEndpointTest::guests_can_not_reach_get_clients_endpoint Expected status code 401 but received 404. Failed asserting that false is true. /Users/philmareu/Code/invoicing/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestResponse.php:77 /Users/philmareu/Code/invoicing/tests/Feature/ClientsEndpointTests/GetClientsEndpointTest.php:20 FAILURES! Tests: 1, Assertions: 1, Failures: 1. ``` Yep, no route. But to move things along, I'll go and make the controller, add the method and the middleware. `routes/web.php` ```php Route::get('api/clients', 'Endpoints\ClientsEndpointController@get'); ``` ```console php artisan make:controller Endpoints/ClientsEndpointController ``` `app/Http/Controllers/Endpoints/ClientsEndpointController.php` ```php class ClientsEndpointController extends Controller { public function __construct() { $this->middleware('auth'); } public function get() { } } ``` ```console $ phpunit --filter guests_can_not_reach_get_clients_endpoint OK (1 test, 1 assertion) ``` Boom! It passed. Ok, let's take a look at the other test. ```php /** * @test */ public function get_clients_endpoint_should_return_a_list_of_all_clients() { $clients = factory(Client::class, 2)->create(); $this->actingAsNewUser() ->json('GET', $this->base) ->assertExactJson($resources->toArray()); } ``` ```console phpunit --filter get_clients_endpoint_should_return_a_list_of_all_clients Error: Class 'Tests\Feature\ClientsEndpointTests\Client' not found ``` Well, we definitely need the `Client` model but what else? Let's make the model, setup the factory and the migration. ```console php artisan make:model Models/Client ``` ```console php artisan make:factory ClientFactory --model=Models/Client ``` `database/factories/ClientFactory.php` ```php $factory->define(Invoicing\Models\Client::class, function (Faker $faker) { return [ 'title' => $faker->company, 'address_1' => $faker->streetAddress, 'address_2' => $faker->streetAddress, 'city' => $faker->city, 'state' => $faker->state, 'zip' => $faker->numberBetween(10000, 99999), 'phone' => $faker->phoneNumber, 'email' => $faker->email ]; }); ``` ```console php artisan make:migration create_clients_table --create=clients ``` `database/migrations/{DATE}_create_clients_table.php` ```php public function up() { Schema::create('clients', function (Blueprint $table) { $table->increments('id'); $table->string('title'); $table->string('email'); $table->string('address_1')->nullable(); $table->string('address_2')->nullable(); $table->string('city')->nullable(); $table->char('state', 2)->nullable(); $table->string('zip')->nullable(); $table->string('phone')->nullable(); $table->timestamps(); }); } ``` Run the test. ``` phpunit --filter get_clients_endpoint_should_return_a_list_of_all_clients Invalid JSON was returned from the route. ``` The get method on the controller has no code that returns a payload. Let's attempt to fix that. `app/Http/Controllers/ClientsEndpointController.php` ```php protected $client; public function __construct(Client $client) { $this->middleware('auth'); $this->client = $client; } public function get() { return $this->client->all(); } ``` Notice I'm injecting in the Client Modal class and then just simply return the full client list. Later down the road, I might switch this out for a repository. But for now, I wanted to keep it simple. Let's run the test! ```console phpunit --filter get_clients_endpoint_should_return_a_list_of_all_clients Failed asserting that two strings are equal. --- Expected +++ Actual -'[{"address_1":"6577 Fannie Estate","address_2":"2924 Flatley Land","city":"East Simone","created_at":"2017-10-04 20:45:35","email":"waino.kulas@hotmail.com","id":2,"phone":"283.347.4681","state":"Utah","title":"Carter Ltd","updated_at":"2017-10-04 20:45:35","zip":36188},{"address_1":"830 Macie Islands Suite 712","address_2":"4716 Armstrong Viaduct Apt. 444","city":"West Melvinfurt","created_at":"2017-10-04 20:45:35","email":"blair.jast@osinski.biz","id":1,"phone":"+16045998797","state":"New Hampshire","title":"Will, Feeney and Windler","updated_at":"2017-10-04 20:45:35","zip":60667}]' +'[{"address_1":"6577 Fannie Estate","address_2":"2924 Flatley Land","city":"East Simone","created_at":"2017-10-04 20:45:35","email":"waino.kulas@hotmail.com","id":2,"phone":"283.347.4681","state":"Utah","title":"Carter Ltd","updated_at":"2017-10-04 20:45:35","zip":"36188"},{"address_1":"830 Macie Islands Suite 712","address_2":"4716 Armstrong Viaduct Apt. 444","city":"West Melvinfurt","created_at":"2017-10-04 20:45:35","email":"blair.jast@osinski.biz","id":1,"phone":"+16045998797","state":"New Hampshire","title":"Will, Feeney and Windler","updated_at":"2017-10-04 20:45:35","zip":"60667"}]' ``` So this is a mess. But how are they different? If you look closely, you will see that the zip code is sent to the database as a number but returned as a string. I would prefer to keep it a string to handle hyphenated zips. So let's change the factory to cast the value as string. `database/factories/ClientFactory.php` ```php 'zip' => (string) $faker->numberBetween(10000, 99999), ``` ```console phpunit --filter get_clients_endpoint_should_return_a_list_of_all_clients OK (1 test, 2 assertions) ``` Perfect. Both tests have passed for the get client endpoint. ### POST Let's do it again for the POST endpoint. I've listed a few validation tests as well, but won't be covering them in this post (see part 1). ```console php artisan make:test ClientsEndpointTests/PostClientsEndpointTest ``` `tests/Feature/ClientsEndpointTests/PostClientsEndpointTest.php` ```php class PostClientsEndpointTest extends TestCase { use RefreshDatabase; protected $base = 'api/clients'; /** * @test */ public function guests_can_not_reach_post_clients_endpoint() { $this->json('POST', $this->base) ->assertStatus(401); } /** * @test */ public function user_can_store_a_new_client() { $client = factory(Client::class)->make(); $this->actingAsNewUser() ->json('POST', $this->base, $client->toArray()) ->assertJson($client->toArray()); $this->assertDatabaseHas('clients', $client->toArray()); } /** * @test */ public function storing_a_client_requires_a_title() { $this->postValidationTest($this->base, 'title'); } /** * @test */ public function storing_a_client_requires_an_email() { $this->postValidationTest($this->base, 'email'); } /** * @test */ public function storing_a_client_requires_a_valid_email() { $this->postValidationTest($this->base, 'email', ['email' => 'not an email']); } } ``` The first test is straight forward and I'm sure by now you know all we need to do is make the route and add store method. `routes/web.php` ```php Route::post('api/clients', 'Endpoints\ClientsEndpointController@store'); ``` `app/Http/Controllers/Endpoints/ClientEndpointsController.php` ```php public function store() { } ``` The second one is a bit different. I'm using the factory `make` method instead of `create`. This will fill the model with attributes but will not save it to the database. After hitting the endpoint with the client data I'm checking the database for the new entry. ```console phpunit --filter user_can_store_a_new_client The table is empty. ``` Let's fix this in the controller. `app/Http/Controllers/Endpoints/ClientEndpointsController.php` ```php public function store(Request $request) { return $this->client->create($request->all()); } ``` Of course, this now returns a `MassAssignmentException`. I will need to define the fillable attributes in the `Client` Model. `app/Models/Client.php` ```php class Client extends Model { protected $fillable = [ 'title', 'email', 'address_1', 'address_2', 'city', 'state', 'zip', 'phone' ]; } ``` ```console phpunit --filter user_can_store_a_new_client OK (1 test, 3 assertions) ``` ### PUT This one is very similiar to POST. Instead of using the factory, I'll augment the existing information and attempt to update. This feels more reliable to me, since the factory could technically return duplicate data for some fields. ```php /** * @test */ public function user_can_update_a_client() { $client = factory(Client::class)->create(); $data = [ 'title' => 'test-' . $client->name, 'email' => 'test-' . $client->email, 'address_1' => 'test-' . $client->address_1, 'address_2' => 'test-' . $client->address_2, 'city' => 'test-' . $client->city, 'state' => 'XX', 'zip' => 'test-' . $client->zip, 'phone' => 'test-' . $client->phone ]; $this->actingAsNewUser() ->json('PUT', $this->base . '/' . $client->id, $data) ->assertJson($data); $client = Client::find($client->id); $this->assertEquals($data['title'], $client->title); $this->assertEquals($data['email'], $client->email); $this->assertEquals($data['address_1'], $client->address_1); $this->assertEquals($data['address_2'], $client->address_2); $this->assertEquals($data['city'], $client->city); $this->assertEquals($data['state'], $client->state); $this->assertEquals($data['zip'], $client->zip); $this->assertEquals($data['phone'], $client->phone); } ``` ```console phpunit --filter user_can_update_a_client Expected status code 200 but received 404. ``` Let's add the route. `routes/web.php` ```php Route::put('api/clients/{client}', 'Endpoints\ClientsEndpointController@update'); ``` I plan on using implicit modal binding which will query for the id and return the actual client model to the controller's update method. To move this along, I'll go ahead and add the method and add the update functionality. `app/Http/Controller/Enpoints/ClientsEndpointController.php` ```php public function update(Request $request, Client $client) { $client->update($request->all()); return $client; } ``` Since I am Typehinting the `Client` model, Laravel will know to query using that model and the id from the uri segment. ```console phpunit --filter user_can_update_a_client OK (1 test, 3 assertions) ``` It passed! ### DELETE Again, let's just focus on the main test that performs the action of deleting a client. Here is the test. ```php /** * @test */ public function user_can_delete_a_client() { $client = factory(Client::class)->create(); $this->actingAs(factory(User::class)->create()) ->json('DELETE', $this->base . '/' . $client->id) ->assertStatus(200); $this->assertDatabaseMissing('clients', ['id' => $client->id]); } ``` So now we need a route, method and a delete action. Let's do that. `routes/web.php` ```php Route::delete('api/clients/{client}', 'Endpoints\ClientsEndpointController@destroy'); ``` `app/Http/Controllers/Endpoints/ClientEndpointsController.php` ```php public function destroy(Client $client) { $client->delete(); } ``` ```console phpunit --filter user_can_delete_a_client OK (1 test, 2 assertions) ``` Yes! ## Refactor Before I go and build the other endpoints, I is definitely worth seeing if there is anything that can be extract as a reuable helper for future tests. I have many more endpoints to build so I will look at everything possible to make it this process more efficient. First, I think it was be worth making 2 abstract classes. One called `EndpointTest`, will be the global class for all the endpoints to extend and another called `ClientsEndpointClass`. These class could contain properties, helpers and traits specifically for these endpoint tests. ### Endpoint Abstraction Class Let's start with creating the `EndpointTest` class. `tests/Feature/EndpointTest.php` ```php namespace Tests\Feature; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; use Tests\ValidationHelperTrait; abstract class EndpointTest extends TestCase { use RefreshDatabase, ValidationHelperTrait; protected $base; protected $class; protected $table; /** * Create a new resource * * @return Model */ protected function createResource() { return factory($this->class)->create(); } /** * Create a resource and return the id * * @return int */ protected function createAndGetResourceId() { return $this->createResource()->id; } /** * Create a resource and build the endpoint combining the base with the resource id * * @return string */ protected function attachIdToBase() { return implode('/', [$this->base, $this->createAndGetResourceId()]); } /** * Find a resource by the id * * @param int $id * @return Model */ protected function findResourceById($id) { return ($this->class)::find($id); } } ``` I've create a few super basic helpers. These are very simple but really help clean up the code and make it more readable. Let's impliment the new class and the helpers on the `PutClientsEndpointTest`. So this ... ```php /** * @test */ public function guests_can_not_reach_put_clients_endpoint() { $this->json('PUT', $this->base . '/' . factory(Client::class)->create()->id) ->assertStatus(401); } ``` Becomes this ... ```php protected $class = Client::class; /** * @test */ public function guests_can_not_reach_put_clients_endpoint() { $this->json('PUT', $this->attachIdToBase())->assertStatus(401); } ``` ### Endpoint Specific Abstraction Class `tests/Feature/Endpoints/ClientsEndpointTest.php` ```php class ClientsEndpointTest extends EndpointTest { protected $base = 'api/clients'; protected $class = Client::class; protected $table = 'clients'; } ``` Currently, this test only holds the properties that will be needed for the verbs tests. These helper classes should make it so much faster to create all the other basis tests.