Support the ongoing development of Laravel.io →
Article Hero Image

Standardizing API Responses Without Traits

19 Jul, 2024 4 min read 386 views

Photo by Philip Oroni on Unsplash

Problem

I've noticed that most libraries created for API responses are implemented using traits, and the rest are large libraries. These traits implement methods for everything imaginable (response, accepted, created, forbidden...).

As a result, if my controller has 1-2 methods, including such a trait brings a lot of unnecessary clutter into the class. In a couple of large libraries with 700+ stars, I see overengineering at the UX level (for me, as a user of the library).

Solution

Write my own library!

I decided to create a data processing logic that would require:

  • minimal actions at the user level
  • simplicity of use
  • readability

That is, to get a standardized response, all we need is to return a response via the library object:

composer require pepperfm/api-responder-for-laravel

As a result, the basic minimum we get right after installing the library is:

Successful response:

{
  "response": {
    "data": {
      "entities": []|{},
      "meta": []|{},
      "message": "Success"
    }
  }
}

Error response:

{
  "response": {
    "data": {
      "errors": null,
      "message": "Error"
    }
  }
}
public function __construct(public ResponseContract $json)
{
}

public function index(Request $request)
{
    $users = User::query()->get();

    return $this->json->response($users);
}

public function store(UserService $service)
{
    try {
        app('db')->beginTransaction();

        $service->update(request()->input());
        
        app('db')->commit();
    } catch (\Exception $e) {
        app('db')->rollback();
        logger()->debug($e->getMessage());

        return $this->json->error(
            message: $e->getMessage(),
            httpStatusCode: $e->getCode()
        );
    }

    return $this->json->response($users);
}

As a result, with a successful response, we have the format unpacked as: response.data.entities. By default, the format is relevant in the context of REST, so for the show() and update() methods, the response will be in the format: response.data.entity.

Deep Dive

Of course, for customization enthusiasts and configuration explorers, I also created a code sandbox to play around with.

Features

Our Favorite Sugar

A wrapper over the response() method for pagination:

/*
 * Generate response.data.meta.pagination from first argument of `paginated()` method
 */
public function index(Request $request)
{
  $users = User::query()->paginate();

  return $this->json->paginated($users);
}

The paginated() method accepts two main parameters:

array|\Illuminate\Pagination\LengthAwarePaginator $data,
array|\Illuminate\Pagination\LengthAwarePaginator $meta = [],

In its logic, it resolves them and adds them to the response under the meta key — pagination key.

Response interfaces according to the format returned by Laravel:

export interface IPaginatedResponse<T> {
    current_page: number
    per_page: number
    last_page: number
    data: T[]
    from: number
    to: number
    total: number
    prev_page_url?: any
    next_page_url: string
    links: IPaginatedResponseLinks[]
}
export interface IPaginatedResponseLinks {
    url?: any
    label: string
    active: boolean
}

As a result, the response looks like:

{
  "response": {
    "data": {
      "entities": []|{},
      "meta": {
        "pagination": IPaginatedResponse<T>
      },
      "message": "Success"
    }
  }
}

A wrapper over the response() method for status codes:

public function store(UserService $service)
{
    // message: 'Stored', httpStatusCode: JsonResponse::HTTP_CREATED
    return $this->json->stored();
}

public function destroy()
{
    // message: 'Deleted', httpStatusCode: JsonResponse::HTTP_NO_CONTENT
    return $this->json->deleted();
}

Working with Different Parameter Types

The first argument of the response() method can be of types array|Arrayable, so data can be mapped before passing to the method within these types. For example:

public function index()
{
    $users = User::query()->paginate();
    $dtoCollection = $users->getCollection()->mapInto(UserDto::class);

    return resolve(ResponseContract::class)->paginated(
        data: $dtoCollection,
        meta: $users
    );
}
public function index()
{
    $users = SpatieUserData::collect(User::query()->get());

    return \ApiBaseResponder::response($users);
}

Customization via Config

The config itself:

return [
    'plural_data_key' => 'entities',

    'singular_data_key' => 'entity',

    'using_for_rest' => true,

    'methods_for_singular_key' => ['show', 'update'],

    'force_json_response_header' => true,
];

Disabling using_for_rest keeps the returned format always response.data.entities (plural) regardless of the method from which the call is made.

With methods_for_singular_key, you can add to the list of methods where the key will be returned in the singular. force_json_response_header essentially adds the header to requests in the classic way: $request->headers->set('Accept', 'application/json');

Customization via Attributes

Blocking using_for_rest and methods_for_singular_key values in the config to set the response key according to singular_data_key:

#[ResponseDataKey]
public function attributeWithoutParam(): JsonResponse
{
    // response.data.entity
    return BaseResponse::response($this->user);
}

Similarly, you can pass your own key name:

#[ResponseDataKey('random_key')]
public function attributeWithParam(): JsonResponse
{
    // response.data.random_key
    return BaseResponse::response($this->user);
}

In Conclusion

The main need is covered: I wanted to be able to simply install the library and have a concise basis for standardizing the response format out of the box. Without unnecessary movements.

And of course, there are still plenty of opportunities for customization and additional sugar, so further development of the library lies ahead) but the main idea will definitely be preserved.

Last updated 1 month ago.

driesvints, pepperfm liked this article

2
Like this article? Let the author know and give them a clap!

Other articles you might like

Article Hero Image November 18th 2024

Laravel Custom Query Builders Over Scopes

Hello 👋 Alright, let's talk about Query Scopes. They're awesome, they make queries much easier to r...

Read article
Article Hero Image November 19th 2024

Access Laravel before and after running Pest tests

How to access the Laravel ecosystem by simulating the beforeAll and afterAll methods in a Pest test....

Read article
Article Hero Image November 11th 2024

🍣 Sushi — Your Eloquent model driver for other data sources

In Laravel projects, we usually store data in databases, create tables, and run migrations. But not...

Read article

We'd like to thank these amazing companies for supporting us

Your logo here?

Laravel.io

The Laravel portal for problem solving, knowledge sharing and community building.

© 2024 Laravel.io - All rights reserved.