Web Frameworks [OGI06i]

06. Web APIs

06.1
Web API Conventions

Recap

06.2
Web APIs Introduction

Before we start: Tools & JSON

  • Use a Web API testing tool such as Postman or Insomnia
  • The Accept: application/json header is very important to Laravel: if you don't indicate you expect JSON, Laravel will send you back HTML pages
  • The Content-Type: application/json header is required when your request contains JSON in the body. If you don't set it, Laravel won't be able to parse the JSON and your $request data will be empty
Insomnia example 1

Web API routing

  • Want to offer a stateless API? Enable API routing and API authentication by
    php artisan install:api*
  • Put all your Web API routes in routes/api.php**
    • The routes in this file are assigned the prefix /api/** !!!
    • Web API communication is assumed to be stateless, so the middlewares applied to routes/api.php are different from those applied to routes/web.php
      (there is no session control, cookie encryption nor CRSF protection, but there is API throttling in stead)
    • You actually ± know how to program Web API endpoints:
      just continue using Controllers, Validation, Eloquent, … you already know
* This will also install the Laravel Sanctum library, including a migration for Web API tokens, in case you want to issue tokens to other developers to access your API

** You can change this prefix and file name in bootstrap/app.php

Will this work?

  • Let's define in routes/api.php
Route::get('/products/{id}', function (string $id): array {

    $product = Product::findOrFail($id);

    return ['title' => $product->title, 'id' => $product->id];

})->where('id', '[0-9]+');
  • Yes! This will work.
    • Laravel will automatically convert an array into a JSON resonse (with the correct Content-Type header)
    • However … this approach becomes problematic as your application grows (less maintainable/reusable)
  • In the next slides, we'll see 2 better approaches:
    • Eloquent Serialization (better)
    • Eloquent API resources (best)
Of course, you'd better use controllers

Error Messages

Insomnia example 2
  • Often have the correct HTTP status code
  • Are only in JSON when the request contains the Accept: application/json header
  • Do not contain the full stack trace when APP_DEBUG=false in .env

06.3
Eloquent Serialization

A Very First Example

  • Let's define in routes/api.php
Route::get('/products/{id}', function (string $id): Product {

    $product = Product::findOrFail($id);

    return $product;

})->where('id', '[0-9]+');
  • When the product exists, the Eloquent model object $product will automatically be serialized into JSON ($product->toJson()) and converted into a Response object
    (with Content-Type: application/json)
  • When the product doesn't exist, findOrFail() launches a NotFoundHttpException resulting in a JSON*-like 404 response
Of course, you'd better use controllers

Demo v1 with Postman

use App\Models\Product;
use Illuminate\Database\Eloquent\Collection;

Route::get('/v1/products/{id}', function (string $id): Product {

    $product = Product::findOrFail($id);

    return $product;
})->where('id', '[0-9]+');

Route::get('/v1/products', function (): Collection {

    return Product::all();
});

Eloquent Serialization (1)

  • Eloquent Serialization determines how exactly the eloquent model is converted into JSON
  • Use $hidden in the Eloquent model class to exclude properties:
class Product extends Model
{
    protected $hidden = ['user_id', 'brand_id'];
    ...
  • Likewise, in the Eloquent model class,
    • protected $visible can be used if you prefer an allow list in stead of $hidden
    • protected $appends can be used to define a list of accessor properties to be included
    • date formats can be altered in protected $casts

Eloquent Serialization (2)

  • Demo v1, revisited
class Product extends Model
{
    protected $hidden = ['user_id', 'brand_id'];
    protected $appends = ['code'];

    protected function code(): Attribute
    {
        return Attribute::make(
            get: fn (mixed $value, array $attributes) => 'P'.sprintf('%06d', $attributes['id']),
        );
    }

    protected function casts(): array
    {
        return [
            'created_at' => 'datetime:Y-m-d H:m:s',
            'updated_at' => 'datetime:Y-m-d H:m:s',
        ];
    }
    ...

Eloquent Serialization (3)

  • Only loaded relationships are (resursively) converted to JSON (unless listed in $hidden)
Route::get('/products/{id}', function (string $id): Product {

    $product = Product::with('brand')->findOrFail($id);

    return $product;

})->where('id', '[0-9]+');
  • You can still override some attribute settings "at run-time":
$product->makeVisible('user_id')
$product->makeHidden('price')
$product->append('html_description')
  • Or override all attribute settings "at run-time":
$product->setVisible(['name', 'description']])
$product->setHidden(['created_at', 'updated_at', 'price'])
$product->setAppends(['code', 'html_description'])

Wrapping

  • Remember that error response? It was inside a message field of a JSON object
  • Some API consumers prefer a uniform response structure
    ⟶ always return a single JSON object with fields like message, data and meta
Route::get('/products/{id}', function (string $id): array {

    $product = Product::with('brand')->findOrFail($id);

    return ['data' => $product];

})->where('id', '[0-9]+');

Demo v2: a CRUD Example

Route::get('/v2/products/{id}', function (string $id): array {

    $product = Product::with('brand')->findOrFail($id);
    return ['data' => $product];

})->where('id', '[0-9]+');

Route::get('/v2/products', function (): array {

    return ['data' => Product::with('brand')->get()];
});

Route::post('/v2/products', function (Request $request): array {

    $request->validate([
        'name' => 'required|unique:products|max:125',
        'price' => 'required|numeric|min:0.10',
        'description' => 'required',
        'brand_id' => 'required|exists:brands,id'
    ]);
    $product = new Product($request->all());
    $product->save();
    return ['message' => 'The product has been created'];
});
Of course, you'd better use controllers

Exercise

  • In almost any endpoint, when a book is returned as JSON, the book should contain
    • the fields id, isbn and title (these are columns in the DB)
    • the field internal_code (which is a concatenation of the book's id and location columns)
    • the related author's JSON
  • Which 4 things should be added to the model class Book?
  • In 1 endpoint, the book should contain the description column in stead of isbn. How can this be overridden?

06.4
Eloquent API Resources

Introduction

  • In certain situations, defining one single JSON response per Eloquent model is not satisfactory
  • Eloquent API Resources form a transformation layer between your Eloquent models and JSON responses
  • You will define Resource classes, defining a transformation between an Eloquent model and a JSON response

Creating a Resource

  • How to create such a transformation?
    php artisan make:resource ProductResource
  • ⟶ a resource class is created in app/Http/Resources
    ⟶ in toArray($request), include all attributes for this transformation
    class ProductResource extends JsonResource
    {
        /**
         * Transform the resource into an array.
         *
         * @return array<string, mixed>
         */
        public function toArray(Request $request): array
        {
            return [
                'id' => $this->id,
                'name' => $this->name,
                'price' => $this->price,
                'brand' => new BrandResource($this->brand),
                'categories' => CategoryResource::collection($this->categories),
            ];
        }
    }

Creating a Resource Collection

  • You don't need to if you have a regular collection!
  • How to create a transformation for collections of models?
    php artisan make:resource ProductCollection
    class name ending on Collection or add the flag --collection
  • ⟶ a resource collection class is created in app/Http/Resources
    ⟶ in toArray($request), define how you want your collection to be transformed
    class ProductCollection extends ResourceCollection
    {
        /**
         * Transform the resource collection into an array.
         *
         * @return array<int|string, mixed>
         */
        public function toArray(Request $request): array
        {
            return [
                'data' => $this->collection,
                'links' => [
                    'self' => 'link-value',
                ],
            ];
        }
    }

Basic Usage

  • Example in your routes/api.php
    use App\Http\Resources\ProductResource;
    use App\Models\Product;
    
    Route::get('/v3/products/{id}', function (string $id): ProductResource {
        return new ProductResource(Product::findOrFail($id));
    });
    
    Route::get('/v3/products', function (): ProductCollection {
        return new ProductCollection(Product::all());
        // or, if you don't have a ProductCollection:
        // return ProductResource::collection(Product::all());
    });
  • By default, all data is wrapped in a data key (configurable)
  • When passing a paginated result, the response contains meta and links keys describing the paginator's state
  • Read more about conditional attributes and conditional relationships

06.5
Sidenote: Resource Controllers

Hint: Resource Controllers

  • Again: you'd better put your code in controllers, having 1 controller per resource
  • The methods and structure of these controllers and corresponding routes will be very similar
  • Streamline your controllers with Resource Controllers

"Web" Resource Controllers

  • Assign all your CRUD routes to a controller in a single line of code
    • Generate the controller:
      $ php artisan make:controller PhotoController --resource
    • Register the routes:
      Route::resource('photos', PhotoController::class); // chain methods like only(), except()
    • Result:
      Verb URI Action Route Name
      GET /photos index photos.index
      GET /photos/create create photos.create
      POST /photos store photos.store
      GET /photos/{photo} show photos.show
      GET /photos/{photo}/edit edit photos.edit
      PUT/PATCH /photos/{photo} update photos.update
      DELETE /photos/{photo} destroy photos.destroy
      Check this out with:
      php artisan route:list
    • In a web app, you will need to spoof the methods DELETE, PUT and PATCH

"API" Resource Controllers

  • Assign all your CRUD routes to a controller in a single line of code
    • Generate the controller:
      $ php artisan make:controller PhotoController --api
    • Register the routes:
      Route::apiResource('photos', PhotoController::class); // chain methods like only(), except()
    • Result:
      Verb URI Action Route Name
      GET /photos index photos.index
      POST /photos store photos.store
      GET /photos/{photo} show photos.show
      PUT/PATCH /photos/{photo} update photos.update
      DELETE /photos/{photo} destroy photos.destroy
      Check this out with:
      php artisan route:list
    • Extend with Nested Resources

06.6
Web API Documentation

Web API Documentation

  • Technical document containing instructions how to effectively use a Web API
  • Concise but containing all information required to work with the Web API
  • Typically: web doc automatically generated from code (annotations)
  • Why is it important? Better dev experience, so better adoption of your Web API, save support time, …

⟶ let's have a look at Swagger UI, generating documentation from OpenAPI specs

Web API Documentation Systems

  1. Annotation-based
  2. Code analysis (automatic)
    • Laradoc is a commercial tool that automatically generates OpenAPI documentation by executing your code and tests in a containerized env
    • Scramble generates Open API documentation just by analyzing your code (only common patterns)
  3. Contract-first
  4. Documentation renderers
    • Swagger UI
    • ReDoc
    • Scalar

Questions?

Sources