In the past, when working with Javascript libraries that required diffrent JSON formatting, I have base models that extended Eloqent and changed the toArray
function.
It was super hacky and required a ton of rework for each different data format that I needed and I couldn't support multiple API formats.
Plus anything in my application (including external libraries) would have to be informed of the changes in the toArray
functionality and contract.
Not to mention, my model class required rewrites for even the most minor change in the structure of data. It really stunk!
After spending some time in the guts of Ember and Ember Data's source code, I realized a cool pattern. They used serializers to take in and consume data formats. And you could extend them to your heart's content!
From there, I looked at my Laravel project and thought where this would fit. If I put the logic in the model again with just a tacked on serializer, I would run into the same issues with breaking the contract and committing to one data format. Plus if I published something that directly mapped to the database, I think that Phil Sturgeon would run me down with his bike.
In the past, I did this manually in repository, but that doesn't feel right. Why should a data fetching object have to also present the data? Nope, I want the repository to do as little as possible (it should do one thing and do it GREAT).
Then I thought that the controller would be a cool way to handle this.
But, I knew I wanted to keep my controller super sexy and thin.
This meant extending a base controller.
It wouldn't do much good if each controller had to know how to inject some object, which gives the chance that I mess up the declaration of the __construct
function.
In reality, if I really wanted to, I wanted to have something where my API controller could look like this:
<?php
class UsersController extends MagicSauceController
{
public function index()
{
return User::all();
}
}
This is no troll either! This wouldn't be my final implementation (I still have to add HTTP header token authentication, authorization, and more,) but I wanted that code to work.
Luckily, Laravel is pretty smart about how the router talks to controllers.
If you use layouts, you've got some idea to how this works.
In Illuminate\Routing\Controller
there is a callAction
function that is used for communication between the router and the controller method you write on a day to day basis.
From here, I am able to hook into the what a controller method returns and then use my serializer class on that response. So, here is a basic version of a my serializing BaseController:
<?php namespace Api;
use Illuminate\Routing\Controller;
use Illuminate\Support\Collection;
abstract class BaseController extends Controller
{
protected $serializer = 'Services\\CamelCaseSerializer';
protected function serializeOutput($values)
{
return $this->serializer->serialize($values);
}
protected function needsSerialization($response)
{
return is_array($response)
|| is_a($response, 'Illuminate\\Support\\Collection')
|| is_a($response, 'Illuminate\\Database\\Eloquent\\Model');
}
protected function addOutputKey($response)
{
if (is_a($response, 'Illuminate\\Database\\Eloquent\\Model')) {
$key = $this->getSingularOutputKey();
} else {
$key = $this->getPluralOutputKey();
}
return new Collection(array($key => $response));
}
protected function getSingularOutputKey()
{
$key = str_singular(class_basename($this));
return $this->serializer->serializeKey($key);
}
protected function getPluralOutputKey()
{
$key = str_plural(class_basename($this));
return $this->serializer->serializeKey($key);
}
/**
* Execute an action on the controller.
*
* @param string $method
* @param array $parameters
* @return \Symfony\Component\HttpFoundation\Response
*/
public function callAction($method, $parameters)
{
$this->setupSerializer();
$response = call_user_func_array(array($this, $method), $parameters);
// Check if the response should be serialized
if ($this->needsSerialization($response))
{
$response = $this->serializeOutput($response);
}
// Check if the response should be keyed
if ($this->needsSerialization($response))
{
$response = $this->addOutputKey($response);
}
return $response;
}
protected function setupSerializer()
{
$this->serializer = app($this->serializer);
}
}
This controller has know idea what data transformation the serializer is doing.
All of the transformation lies within the Services\CamelCaseSerializer
class.
<?php namespace Services;
use Illuminate\Support\Str;
class CamelCaseSerializer
{
public function __construct(Str $str)
{
$this->str = $str;
}
public function serialize($data)
{
switch ($data) {
case is_a($data, 'Illuminate\\Support\\Collection'):
$serializedData = $this->serializeCollection($data);
break;
case is_a($data, 'Illuminate\\Database\\Eloquent\\Model'):
$serializedData = $this->serializeEloquentModel($data);
break;
case is_array($data):
$serializedData = $this->serializeArray($data);
break;
default:
$serializedData = $data;
}
return $serializedData;
}
public function serializeKey($key)
{
return $this->str->camel($key);
}
protected function serializeCollection($collection)
{
return $collection->map(function($data) {
return $this->serialize($data);
});
}
protected function serializeEloquentModel($model)
{
return $this->serializeArray($model->toArray());
}
protected function serializeArray(array $data)
{
$serializedData = array();
foreach ($data as $key => $value) {
$key = $this->serializeKey($key);
$value = $this->serialize($value);
$serializedData[$key] = $value;
}
return $serializedData;
}
}
The output from the User controller from earlier looks a bit like this:
{
"users": [
{
"id": 1,
"email": "foo@example.com",
"userName": "foo_bar"
}
]
}
Now, if I wanted to create a serializer that transforms my data some other way, but only for a single controller or group of controllers, I can just set $serializer
on that controller instance to MyMagicSerializer
's class name.
As Jeffrey Way pointed out, this is still a very simple implementation. Before pulling this out of my project and building a package, I hope to do some of the following:
- Inject status codes and meta data
- Handle paginators (right now those would be all messed up)
- Handle errors and set statuses and messages