Time to build our second API endpoint.
A public (no auth) endpoint to get a list of paginated tours by the travel slug
(e.g. all the tours of the travel foo-bar
).
Users can filter (search) the results by priceFrom
, priceTo
, dateFrom
(from that startingDate
) and dateTo
(until that startingDate
). User can sort the list by price
asc and desc. They will always be sorted, after every additional user-provided filter, by startingDate
asc.
In this lesson, let's build the endpoint itself with automated tests. In the next lesson, we will sort and filter data.
So, similarly to the Travel list lesson, a new Controller:
php artisan make:controller Api/V1/TourController
Also, let's make an API Resource right away:
php artisan make:resource TourResource
Finally, the Route. The client specified that Travel slug
should be the parameter. So, we're aiming for the endpoint like /api/v1/travels/[travels.slug]/tours
.
So, we write this:
routes/api.php:
use App\Http\Controllers\Api\V1\TourController; Route::get('travels/{travel:slug}/tours', [TourController::class, 'index']);
So, we use Route Model Binding here, specifying the field slug
to search for. If we don't specify that, the record will be searched by the travels.id
field.
Alternatively, you may specify that field globally for all the future Route Model Binding for Travel in the Model itself.
app/Models/Travel.php:
public function getRouteKeyName(): string{ return 'slug';}
So now, we can fill in the Controller like this:
app/Http/Controllers/Api/V1/TourController.php:
namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller;use App\Http\Resources\TourResource;use App\Models\Travel; class TourController extends Controller{ public function index(Travel $travel) { $tours = $travel->tours() ->orderBy('starting_date') ->paginate(); return TourResource::collection($tours); }}
A few things to notice here:
Travel $travel
is exactly the result of the automatic Route Model Binding mentioned earlier. If the Travel record isn't found by slug
, Laravel will return the 404 status code.$travel->tours()
relationship of that model.starting_date
. In the next lesson, we will add customizable ordering provided by the user.Now, what's inside that API Resource file?
Similarly to the Travel Resource in the previous lesson, we just list the fields we want to return.
app/Http/Resources/TravelResource.php:
public function toArray(Request $request): array{ return [ 'id' => $this->id, 'name' => $this->name, 'starting_date' => $this->starting_date, 'ending_date' => $this->ending_date, 'price' => number_format($this->price, 2), ];}
We don't do many transformations here, except for hiding the timestamps and transforming the price to be returned with two decimal places as 5.00
even if it's 5
.
So, I've added a testing record via DB client and launched it in the Postman. Works!
As this feature is working, we need to test that it's accurate. It will be similar to the tests for the Travel list endpoint in the previous lesson.
php artisan make:test ToursListTest
Inside that test file, we will test three things, described by self-explanatory method names:
test_tours_list_by_travel_slug_returns_correct_tours()
test_tour_price_is_shown_correctly()
test_tours_list_returns_pagination()
Again, to set up those situations, we need fake data with Factories.
php artisan make:factory TourFactory --model=Tour
And here are the rules for the fields:
database/factories/TourFactory.php:
public function definition(): array{ return [ 'name' => fake()->text(20), 'starting_date' => now(), 'ending_date' => now()->addDays(rand(1, 10)), 'price' => fake()->randomFloat(2, 10, 999), ];}
Now, the first test method will look like this:
tests/Feature/ToursListTest.php:
namespace Tests\Feature; use App\Models\Tour;use App\Models\Travel;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase; class ToursListTest extends TestCase{ use RefreshDatabase; public function test_tours_list_by_travel_slug_returns_correct_tours(): void { $travel = Travel::factory()->create(); $tour = Tour::factory()->create(['travel_id' => $travel->id]); $response = $this->get('/api/v1/travels/'.$travel->slug.'/tours'); $response->assertStatus(200); $response->assertJsonCount(1, 'data'); $response->assertJsonFragment(['id' => $tour->id]); } }
Need any explanations? Pretty sure it's clear.
Similarly, let's add two more tests that will also be pretty clear, not needing much to explain.
tests/Feature/ToursListTest.php:
public function test_tour_price_is_shown_correctly(): void{ $travel = Travel::factory()->create(); Tour::factory()->create([ 'travel_id' => $travel->id, 'price' => 123.45, ]); $response = $this->get('/api/v1/travels/'.$travel->slug.'/tours'); $response->assertStatus(200); $response->assertJsonCount(1, 'data'); $response->assertJsonFragment(['price' => '123.45']);} public function test_tours_list_returns_pagination(): void{ $travel = Travel::factory()->create(); Tour::factory(16)->create(['travel_id' => $travel->id]); $response = $this->get('/api/v1/travels/'.$travel->slug.'/tours'); $response->assertStatus(200); $response->assertJsonCount(15, 'data'); $response->assertJsonPath('meta.last_page', 2);}
And we launch the tests with the php artisan test
. Works!
In the next lesson, we will add filtering and sorting to this endpoint.
number_format()
method: Official Docs
randomFloat()
for Numbers and Strings: Official Docs or Video: Faker in Laravel - 10+ Less-Known Methods