Laravel の JsonResource 内の Carbon が UCT で JSON に出力される件
コントローラが Model を return した場合
こんなモデルがあったとして、
<?php
/**
* ユーザマスタ
*
* @property integer $user_id
* @property string $user_name
* @property string $email
* @property string $password
* @property \Illuminate\Support\Carbon $register_datetime
* @property \Illuminate\Support\Carbon $created_at
* @property \Illuminate\Support\Carbon $updated_at
* @property \Illuminate\Support\Carbon $deleted_at
*/
class User extends Model implements
AuthenticatableContract,
AuthorizableContract,
CanResetPasswordContract
{
use Authenticatable, Authorizable, CanResetPassword, MustVerifyEmail, SoftDelete;
protected $table = "m_users";
protected $primaryKey = 'user_id';
protected $dates = [
'register_datetime',
'created_at',
'updated_at',
];
protected $casts = [
'user_id' => 'integer',
'register_datetime' => 'date:Y-m-d H:i:s',
'created_at' => 'date:Y-m-d H:i:s',
'updated_at' => 'date:Y-m-d H:i:s',
'deleted_at' => 'date:Y-m-d H:i:s',
];
}
コントローラが、以下のようにモデルを直接返却すると、
class MeController extends Controller
{
public function __invoke() : User
{
/** @var User $user */
$user = Auth::user();
return $user;
}
}
レスポンスはこうなります。
{
"user_id": 1,
"user_name": "Roku",
"email": "roku@example.com",
"password": "$2y$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"register_datetime": "2022-07-31T23:00:00.000000Z",
"created_at": "2022-07-31T23:00:00.000000Z",
"updated_at": "2022-07-31T23:00:00.000000Z",
"deleted_by": null
}
なお、DB に格納されている register_datetime その他の値は
2022-08-01 08:00:00 です。
つまり、UCT で返っています。
Laravel の config/app.php の timezone は、
Asia/Tokyo になっているものとします。
後述の通り、この場合、Model::toJson() がコールされます。
Model::toJson() から追いかけていくと、
Model が配列化されていく過程で、Dates プロパティについては、
HasAttributes トレイトの serializeDate() で変換されていることがわかります。
protected function serializeDate(DateTimeInterface $date)
{
return $date instanceof \DateTimeImmutable ?
CarbonImmutable::instance($date)->toJSON() :
Carbon::instance($date)->toJSON();
}
ところが、Carbon::toJson() はタイムゾーンを考慮してくれないため、
Laravel の設定に反して UTC になってしまいます。
APIのレスポンスを JST にしたいなら、
例えば Model で上記をオーバーライドして、
protected function serializeDate(DateTimeInterface $date)
{
return $date->format('Y-m-d H:i:s');
}
等とする方法があります。
コントローラが JsonResource を return した場合
一方、Controller が以下のように JsonResource を返している場合、
class MeController extends Controller
{
public function __invoke() : User
{
/** @var User $user */
$user = Auth::user();
return new UserResource($user);
}
}
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'user_id' => $this->user_id,
'user_name' => $this->user_name,
'email' => $this->email,
'register_datetime' => $this->register_datetime,
];
}
}
それでもレスポンスはこうなります。
{
"user_id": 1,
"user_name": "Roku",
"email": "roku@example.com",
"register_datetime": "2022-07-31T23:00:00.000000Z",
}
JsonResource を追いかけてみると、
public function jsonSerialize()
{
return $this->resolve(Container::getInstance()->make('request'));
}
public function resolve($request = null)
{
$data = $this->toArray(
$request = $request ?: Container::getInstance()->make('request')
);
if ($data instanceof Arrayable) {
$data = $data->toArray();
} elseif ($data instanceof JsonSerializable) {
$data = $data->jsonSerialize();
}
return $this->filter((array) $data);
}
protected function filter($data)
{
$index = -1;
foreach ($data as $key => $value) {
$index++;
if (is_array($value)) {
$data[$key] = $this->filter($value);
continue;
}
if (is_numeric($key) && $value instanceof MergeValue) {
return $this->mergeData(
$data, $index, $this->filter($value->data),
array_values($value->data) === $value->data
);
}
if ($value instanceof self && is_null($value->resource)) {
$data[$key] = null;
}
}
return $this->removeMissingValues($data);
}
Carbon については、何ら例外的な処理を行わないまま、
そのまま json_encode されています。
Carbon::jsonSerialize() を追いかけてみると、
Carbon::serializeUsing() によって、
Carbon に独自のシリアライズ方式を定義することもできるようですが、
このメソッドは deprecated になっています。
そこで、JsonResource によって生成されるレスポンスをJSTにするには、
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'user_id' => $this->user_id,
'user_name' => $this->user_name,
'email' => $this->email,
'register_datetime' => $this->register_datetime->format('Y-m-d H:i:s'),
];
}
}
のように、個々の Resource クラスで変換するか、
class BaseResource extends JsonResource
{
protected function filter($data)
{
$data = parent::filter($data);
foreach ($data as $key => $value) {
if ($value instanceof CarbonInterface) {
$data[$key] = $value->format('Y-m-d H:i:s');
} else {
$data[$key] = $value;
}
}
return $data;
}
}
JsonResource::resolve() なり JsonResource::filter() なりを、
オーバーライドすることになりそうです。
補足1 JsonSerializable インターフェイス
例えば以下のような Entity チックなクラスのオブジェクトを
json_encode した場合、
<?php
class User
{
private string $user_name;
private string $email;
public function setUserName(string $user_name)
{
$this->user_name = $user_name;
}
public function setEmail(string $email)
{
$this->email = $email;
}
}
$user = new User();
$user->setUserName('Roku');
$user->setEmail('roku@example.com');
echo json_encode($user, JSON_PRETTY_PRINT);
{}
private なプロパティは、JSONとして出力されません。
また、public なプロパティも、全てそのまま出力されます。
以下のように、JsonSerializable インターフェイスを実装すれば、
json_encode() された場合の挙動を変更することができます。
<?php
class User implements JsonSerializable
{
private string $user_name;
private string $email;
public function setUserName(string $user_name)
{
$this->user_name = $user_name;
}
public function setEmail(string $email)
{
$this->email = $email;
}
public function jsonSerialize()
{
return [
'user_name' => $this->user_name,
'email' => $this->email,
];
}
}
$user = new User();
$user->setUserName('Roku');
$user->setEmail('roku@example.com');
echo json_encode($user, JSON_PRETTY_PRINT);
{
"user_name": "Roku",
"email": "roku@example.com"
}
なお、Laravel のソース内では、以下のように、
JsonSerializable であれば、jsonSerialize() を呼んでから json_encode する、
という実装が散見されますが、
if ($data instanceof JsonSerializable) {
$json = json_encode($data->jsonSerialize());
}
本来 JsonSerializable::jsonSerialize() は、
json_encode() で内部的に呼ばれるので、
それ以外の条件に優先する必要がなければ、上記のような実装は不要です。
補足2 コントローラから return された値の行方
コントローラから return された値は、
Illuminate\Routing\Router::toResponse() で
Response に変換されます。
public static function toResponse($request, $response)
{
if ($response instanceof Responsable) {
$response = $response->toResponse($request);
}
if ($response instanceof PsrResponseInterface) {
$response = (new HttpFoundationFactory)->createResponse($response);
} elseif ($response instanceof Model && $response->wasRecentlyCreated) {
$response = new JsonResponse($response, 201);
} elseif ($response instanceof Stringable) {
$response = new Response($response->__toString(), 200, ['Content-Type' => 'text/html']);
} elseif (! $response instanceof SymfonyResponse &&
($response instanceof Arrayable ||
$response instanceof Jsonable ||
$response instanceof ArrayObject ||
$response instanceof JsonSerializable ||
$response instanceof \stdClass ||
is_array($response))) {
$response = new JsonResponse($response);
} elseif (! $response instanceof SymfonyResponse) {
$response = new Response($response, 200, ['Content-Type' => 'text/html']);
}
if ($response->getStatusCode() === Response::HTTP_NOT_MODIFIED) {
$response->setNotModified();
}
return $response->prepare($request);
}
コントローラ が JsonResource を return した場合、
JsonResource は Responsable であるため、
JsonResource::toResponse() がコールされます。
その先の道のりもまだ長いですが、
JsonResource の諸々のプロパティを配列化した後、
Illuminate\Http\JsonResponse (以下 JsonResponse) に変換されています。
一方、コントローラ が Model を return した場合、
上記1つめの elseif 節で JsonResponse に変換されています。
そして最終的に JsonResponse::setData() で、
public function setData($data = [])
{
$this->original = $data;
if ($data instanceof Jsonable) {
$this->data = $data->toJson($this->encodingOptions);
} elseif ($data instanceof JsonSerializable) {
$this->data = json_encode($data->jsonSerialize(), $this->encodingOptions);
} elseif ($data instanceof Arrayable) {
$this->data = json_encode($data->toArray(), $this->encodingOptions);
} else {
$this->data = json_encode($data, $this->encodingOptions);
}
if (! $this->hasValidJson(json_last_error())) {
throw new InvalidArgumentException(json_last_error_msg());
}
return $this->update();
}
JsonResource の場合は、前処理で既に配列に変換されているため、
else 節に入り、そのまま json_encode されます。
Model は Jsonable なので、Model::toJson() が呼ばれることになります。
Discussion