API: Use resource classes to serialize models

This commit is contained in:
Igor Scheller 2023-11-15 02:29:58 +01:00 committed by Michael Weimann
parent 5b237febf8
commit 8894f183f2
22 changed files with 351 additions and 94 deletions

View File

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Engelsystem\Controllers\Api; namespace Engelsystem\Controllers\Api;
use Engelsystem\Controllers\Api\Resources\AngelTypeResource;
use Engelsystem\Controllers\Api\Resources\UserAngelTypeResource;
use Engelsystem\Http\Request; use Engelsystem\Http\Request;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Models\AngelType; use Engelsystem\Models\AngelType;
@ -15,13 +17,9 @@ class AngelTypeController extends ApiController
{ {
$models = AngelType::query() $models = AngelType::query()
->orderBy('name') ->orderBy('name')
->get(['id', 'name', 'description']); ->get();
$models->map(function (AngelType $model): void { $data = ['data' => AngelTypeResource::collection($models)];
$model->url = $this->getUrl($model);
});
$data = ['data' => $models];
return $this->response return $this->response
->withContent(json_encode($data)); ->withContent(json_encode($data));
} }
@ -30,31 +28,9 @@ class AngelTypeController extends ApiController
{ {
$id = (int) $request->getAttribute('user_id'); $id = (int) $request->getAttribute('user_id');
$model = User::findOrFail($id); $model = User::findOrFail($id);
$data = ['data' => UserAngelTypeResource::collection($model->userAngelTypes)];
$models = $model->userAngelTypes()->get([
'angel_types.id',
'angel_types.name',
'angel_types.description',
'angel_types.restricted',
]);
$data = [];
$models->map(function (AngelType $model) use (&$data): void {
$model->confirmed = !$model->restricted || $model->pivot->supporter || $model->pivot->confirm_user_id;
$model->supporter = $model->pivot->supporter;
$model->url = $this->getUrl($model);
$modelData = $model->attributesToArray();
unset($modelData['restricted']);
$data[] = $modelData;
});
$data = ['data' => $data];
return $this->response return $this->response
->withContent(json_encode($data)); ->withContent(json_encode($data));
} }
protected function getUrl(AngelType $model): string
{
return $this->url->to('/angeltypes', ['action' => 'view', 'angeltype_id' => $model->id]);
}
} }

View File

@ -6,7 +6,6 @@ namespace Engelsystem\Controllers\Api;
use Engelsystem\Controllers\BaseController; use Engelsystem\Controllers\BaseController;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Http\UrlGeneratorInterface;
abstract class ApiController extends BaseController abstract class ApiController extends BaseController
{ {
@ -14,7 +13,7 @@ abstract class ApiController extends BaseController
'api', 'api',
]; ];
public function __construct(protected Response $response, protected UrlGeneratorInterface $url) public function __construct(protected Response $response)
{ {
$this->response = $this->response $this->response = $this->response
->withHeader('content-type', 'application/json') ->withHeader('content-type', 'application/json')

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Engelsystem\Controllers\Api; namespace Engelsystem\Controllers\Api;
use Engelsystem\Controllers\Api\Resources\LocationResource;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Models\Location; use Engelsystem\Models\Location;
@ -13,13 +14,9 @@ class LocationsController extends ApiController
{ {
$models = Location::query() $models = Location::query()
->orderBy('name') ->orderBy('name')
->get(['id', 'name']); ->get();
$models->map(function (Location $model): void { $data = ['data' => LocationResource::collection($models)];
$model->url = $this->url->to('/locations', ['action' => 'view', 'location_id' => $model->id]);
});
$data = ['data' => $models];
return $this->response return $this->response
->withContent(json_encode($data)); ->withContent(json_encode($data));
} }

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Engelsystem\Controllers\Api; namespace Engelsystem\Controllers\Api;
use Engelsystem\Controllers\Api\Resources\NewsResource;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Models\News; use Engelsystem\Models\News;
@ -14,13 +15,9 @@ class NewsController extends ApiController
$models = News::query() $models = News::query()
->orderByDesc('updated_at') ->orderByDesc('updated_at')
->orderByDesc('created_at') ->orderByDesc('created_at')
->get(['id', 'title', 'text', 'is_meeting', 'is_pinned', 'is_highlighted', 'created_at', 'updated_at']); ->get();
$models->map(function (News $model): void { $data = ['data' => NewsResource::collection($models)];
$model->url = $this->url->to('/news/' . $model->id);
});
$data = ['data' => $models];
return $this->response return $this->response
->withContent(json_encode($data)); ->withContent(json_encode($data));
} }

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Api\Resources;
class AngelTypeResource extends BasicResource
{
public function toArray(): array
{
return [
'id' => $this->model->id,
'name' => $this->model->name,
'description' => $this->model->description,
'url' => url('/angeltypes', ['action' => 'view', 'angeltype_id' => $this->model->id]),
];
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Api\Resources;
use Engelsystem\Models\BaseModel;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Support\Collection;
use Stringable;
/** @phpstan-consistent-constructor */
abstract class BasicResource implements Arrayable, Jsonable, Stringable
{
public function __construct(protected BaseModel|Collection $model)
{
}
/**
* @param iterable|Collection|BaseModel[]|Collection[] $data
*/
public static function collection(iterable $data): Collection
{
$collection = new Collection();
foreach ($data as $item) {
$collection->add(new static($item));
}
return $collection;
}
public function toArray(): array
{
return $this->model->toArray();
}
/**
* @param int $options
*/
public function toJson($options = 0): string // phpcs:ignore
{
return json_encode($this->toArray(), $options);
}
public function __toString(): string
{
return $this->toJson();
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Api\Resources;
class LocationResource extends BasicResource
{
public function toArray(): array
{
return [
'id' => $this->model->id,
'name' => $this->model->name,
'url' => url('/locations', ['action' => 'view', 'location_id' => $this->model->id]),
];
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Api\Resources;
class NewsResource extends BasicResource
{
public function toArray(): array
{
return [
'id' => $this->model->id,
'title' => $this->model->title,
'text' => $this->model->text,
'is_meeting' => $this->model->is_meeting,
'is_pinned' => $this->model->is_pinned,
'is_highlighted' => $this->model->is_highlighted,
'created_at' => $this->model->created_at,
'updated_at' => $this->model->updated_at,
'url' => url('/news/' . $this->model->id),
];
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Api\Resources;
use Illuminate\Contracts\Support\Arrayable;
class ShiftResource extends BasicResource
{
public function toArray(array|Arrayable $location = []): array
{
return [
'id' => $this->model->id,
'title' => $this->model->title,
'description' => $this->model->description,
'starts_at' => $this->model->start,
'ends_at' => $this->model->end,
'location' => $location instanceof Arrayable ? $location->toArray() : $location,
'shift_type' => (new ShiftTypeResource($this->model->shiftType))->toArray(),
'created_at' => $this->model->created_at,
'updated_at' => $this->model->updated_at,
'url' => url('/shifts', ['action' => 'view', 'shift_id' => $this->model->id]),
];
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Api\Resources;
class ShiftTypeResource extends BasicResource
{
public function toArray(): array
{
return [
'id' => $this->model->id,
'name' => $this->model->name,
'description' => $this->model->description,
];
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Api\Resources;
use Illuminate\Contracts\Support\Arrayable;
class ShiftWithEntriesResource extends ShiftResource
{
public function toArray(array|Arrayable $location = [], array|Arrayable $entries = []): array
{
return [
...parent::toArray($location),
'entries' => $entries instanceof Arrayable ? $entries->toArray() : $entries,
];
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Api\Resources;
class UserAngelTypeResource extends AngelTypeResource
{
public function toArray(): array
{
return [
...parent::toArray(),
'confirmed' => !$this->model->restricted
|| $this->model->pivot->supporter
|| $this->model->pivot->confirm_user_id,
'supporter' => $this->model->pivot->supporter,
];
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Api\Resources;
class UserResource extends BasicResource
{
public function toArray(): array
{
return [
'id' => $this->model->id,
'name' => $this->model->name,
'first_name' => $this->model->personalData->first_name,
'last_name' => $this->model->personalData->last_name,
'pronoun' => $this->model->personalData->pronoun,
'contact' => $this->model->contact->only(['dect', 'mobile']),
'url' => url('/users', ['action' => 'view', 'user_id' => $this->model->id]),
];
}
}

View File

@ -4,6 +4,10 @@ declare(strict_types=1);
namespace Engelsystem\Controllers\Api; namespace Engelsystem\Controllers\Api;
use Engelsystem\Controllers\Api\Resources\AngelTypeResource;
use Engelsystem\Controllers\Api\Resources\LocationResource;
use Engelsystem\Controllers\Api\Resources\ShiftWithEntriesResource;
use Engelsystem\Controllers\Api\Resources\UserResource;
use Engelsystem\Http\Request; use Engelsystem\Http\Request;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Models\Location; use Engelsystem\Models\Location;
@ -39,53 +43,23 @@ class ShiftsController extends ApiController
$entries = new Collection(); $entries = new Collection();
foreach ($neededAngelTypes as $neededAngelType) { foreach ($neededAngelTypes as $neededAngelType) {
$users = []; $users = UserResource::collection($neededAngelType->users ?? []);
foreach ($neededAngelType->users ?? [] as $user) {
$users[] = [
'id' => $user->id,
'name' => $user->name,
'first_name' => $user->personalData->first_name,
'last_name' => $user->personalData->last_name,
'pronoun' => $user->personalData->pronoun,
'contact' => $user->contact->only(['dect', 'mobile']),
'url' => $this->url->to('/users', ['action' => 'view', 'user_id' => $user->id]),
];
}
// Skip empty entries // Skip empty entries
if ($neededAngelType->count <= 0 && empty($users)) { if ($neededAngelType->count <= 0 && $users->isEmpty()) {
continue; continue;
} }
$angelTypeData = $neededAngelType->angelType->only(['id', 'name', 'description']); $angelTypeData = new AngelTypeResource($neededAngelType->angelType);
$angelTypeData['url'] = $this->url->to( $entries[] = new Collection([
'/angeltypes',
['action' => 'view', 'angeltype_id' => $neededAngelType->angelType->id]
);
$entries[] = [
'users' => $users, 'users' => $users,
'type' => $angelTypeData, 'type' => $angelTypeData,
'needs' => $neededAngelType->count, 'needs' => $neededAngelType->count,
]; ]);
} }
$locationData = $location->only(['id', 'name']); $locationData = new LocationResource($location);
$locationData['url'] = $this->url->to('/locations', ['action' => 'view', 'location_id' => $location->id]); $shiftEntries[] = (new ShiftWithEntriesResource($shift))->toArray($locationData, $entries);
$shiftEntries[] = [
'id' => $shift->id,
'title' => $shift->title,
'description' => $shift->description,
'starts_at' => $shift->start,
'ends_at' => $shift->end,
'location' => $locationData,
'shift_type' => $shift->shiftType->only(['id', 'name', 'description']),
'created_at' => $shift->created_at,
'updated_at' => $shift->updated_at,
'entries' => $entries,
'url' => $this->url->to('/shifts', ['action' => 'view', 'shift_id' => $shift->id]),
];
} }
$data = ['data' => $shiftEntries]; $data = ['data' => $shiftEntries];

View File

@ -15,14 +15,13 @@ class AngelTypeControllerTest extends ApiBaseControllerTest
{ {
/** /**
* @covers \Engelsystem\Controllers\Api\AngelTypeController::index * @covers \Engelsystem\Controllers\Api\AngelTypeController::index
* @covers \Engelsystem\Controllers\Api\AngelTypeController::getUrl
*/ */
public function testIndex(): void public function testIndex(): void
{ {
$this->initDatabase(); $this->initDatabase();
$items = AngelType::factory(3)->create(); $items = AngelType::factory(3)->create();
$controller = new AngelTypeController(new Response(), $this->url); $controller = new AngelTypeController(new Response());
$response = $controller->index(); $response = $controller->index();
$this->validateApiResponse('/angeltypes', 'get', $response); $this->validateApiResponse('/angeltypes', 'get', $response);
@ -39,6 +38,7 @@ class AngelTypeControllerTest extends ApiBaseControllerTest
} }
/** /**
* @covers \Engelsystem\Controllers\Api\AngelTypeController::ofUser * @covers \Engelsystem\Controllers\Api\AngelTypeController::ofUser
* @covers \Engelsystem\Controllers\Api\Resources\UserAngelTypeResource::toArray
*/ */
public function testOfUser(): void public function testOfUser(): void
{ {
@ -46,7 +46,7 @@ class AngelTypeControllerTest extends ApiBaseControllerTest
$user = User::factory()->create(); $user = User::factory()->create();
$items = UserAngelType::factory(3)->create(['user_id' => $user->id]); $items = UserAngelType::factory(3)->create(['user_id' => $user->id]);
$controller = new AngelTypeController(new Response(), $this->url); $controller = new AngelTypeController(new Response());
$response = $controller->ofUser(new Request([], [], ['user_id' => $user->id])); $response = $controller->ofUser(new Request([], [], ['user_id' => $user->id]));
$this->validateApiResponse('/users/{id}/angeltypes', 'get', $response); $this->validateApiResponse('/users/{id}/angeltypes', 'get', $response);

View File

@ -15,7 +15,6 @@ use Psr\Http\Message\ResponseInterface;
abstract class ApiBaseControllerTest extends TestCase abstract class ApiBaseControllerTest extends TestCase
{ {
protected OpenApiResponseValidator $validator; protected OpenApiResponseValidator $validator;
protected UrlGeneratorInterface $url;
protected function validateApiResponse(string $path, string $method, ResponseInterface $response): void protected function validateApiResponse(string $path, string $method, ResponseInterface $response): void
{ {
@ -41,6 +40,6 @@ abstract class ApiBaseControllerTest extends TestCase
$query = http_build_query($params); $query = http_build_query($params);
return $path . ($query ? '?' . $query : ''); return $path . ($query ? '?' . $query : '');
}); });
$this->url = $url; $this->app->instance('http.urlGenerator', $url);
} }
} }

View File

@ -14,7 +14,7 @@ class ApiControllerTest extends ApiBaseControllerTest
*/ */
public function testConstruct(): void public function testConstruct(): void
{ {
$controller = new class (new Response('{"some":"json"}'), $this->url) extends ApiController { $controller = new class (new Response('{"some":"json"}')) extends ApiController {
public function getResponse(): Response public function getResponse(): Response
{ {
return $this->response; return $this->response;

View File

@ -15,7 +15,7 @@ class IndexControllerTest extends ApiBaseControllerTest
*/ */
public function testIndex(): void public function testIndex(): void
{ {
$controller = new IndexController(new Response(), $this->url); $controller = new IndexController(new Response());
$response = $controller->index(); $response = $controller->index();
$this->assertEquals(200, $response->getStatusCode()); $this->assertEquals(200, $response->getStatusCode());
@ -32,7 +32,7 @@ class IndexControllerTest extends ApiBaseControllerTest
*/ */
public function testIndexV0(): void public function testIndexV0(): void
{ {
$controller = new IndexController(new Response(), $this->url); $controller = new IndexController(new Response());
$response = $controller->indexV0(); $response = $controller->indexV0();
$this->assertEquals(200, $response->getStatusCode()); $this->assertEquals(200, $response->getStatusCode());
@ -48,7 +48,7 @@ class IndexControllerTest extends ApiBaseControllerTest
*/ */
public function testOptions(): void public function testOptions(): void
{ {
$controller = new IndexController(new Response(), $this->url); $controller = new IndexController(new Response());
$response = $controller->options(); $response = $controller->options();
$this->assertEquals(200, $response->getStatusCode()); $this->assertEquals(200, $response->getStatusCode());
@ -61,7 +61,7 @@ class IndexControllerTest extends ApiBaseControllerTest
*/ */
public function testNotFound(): void public function testNotFound(): void
{ {
$controller = new IndexController(new Response(), $this->url); $controller = new IndexController(new Response());
$response = $controller->notFound(); $response = $controller->notFound();
$this->assertEquals(404, $response->getStatusCode()); $this->assertEquals(404, $response->getStatusCode());
@ -74,7 +74,7 @@ class IndexControllerTest extends ApiBaseControllerTest
*/ */
public function testNotImplemented(): void public function testNotImplemented(): void
{ {
$controller = new IndexController(new Response(), $this->url); $controller = new IndexController(new Response());
$response = $controller->notImplemented(); $response = $controller->notImplemented();
$this->assertEquals(405, $response->getStatusCode()); $this->assertEquals(405, $response->getStatusCode());

View File

@ -12,12 +12,13 @@ class LocationsControllerTest extends ApiBaseControllerTest
{ {
/** /**
* @covers \Engelsystem\Controllers\Api\LocationsController::index * @covers \Engelsystem\Controllers\Api\LocationsController::index
* @covers \Engelsystem\Controllers\Api\Resources\LocationResource::toArray
*/ */
public function testIndex(): void public function testIndex(): void
{ {
$items = Location::factory(3)->create(); $items = Location::factory(3)->create();
$controller = new LocationsController(new Response(), $this->url); $controller = new LocationsController(new Response());
$response = $controller->index(); $response = $controller->index();
$this->validateApiResponse('/locations', 'get', $response); $this->validateApiResponse('/locations', 'get', $response);

View File

@ -12,12 +12,13 @@ class NewsControllerTest extends ApiBaseControllerTest
{ {
/** /**
* @covers \Engelsystem\Controllers\Api\NewsController::index * @covers \Engelsystem\Controllers\Api\NewsController::index
* @covers \Engelsystem\Controllers\Api\Resources\NewsResource::toArray
*/ */
public function testIndex(): void public function testIndex(): void
{ {
$items = News::factory(3)->create(); $items = News::factory(3)->create();
$controller = new NewsController(new Response(), $this->url); $controller = new NewsController(new Response());
$response = $controller->index(); $response = $controller->index();
$this->validateApiResponse('/news', 'get', $response); $this->validateApiResponse('/news', 'get', $response);

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Test\Unit\Controllers\Api\Resources;
use Engelsystem\Controllers\Api\Resources\BasicResource;
use Engelsystem\Models\BaseModel;
use Engelsystem\Test\Unit\TestCase;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Support\Collection;
use Stringable;
class BasicResourceTest extends TestCase
{
/**
* @covers \Engelsystem\Controllers\Api\Resources\BasicResource::__construct
* @covers \Engelsystem\Controllers\Api\Resources\BasicResource::toArray
*/
public function testToArray(): void
{
$model = $this->getModel();
$resource = $this->getResource($model);
$this->assertInstanceOf(Arrayable::class, $resource);
$this->assertEquals(['test' => 'value'], $resource->toArray());
}
/**
* @covers \Engelsystem\Controllers\Api\Resources\BasicResource::toJson
*/
public function testToJson(): void
{
$model = $this->getModel();
$resource = $this->getResource($model);
$this->assertInstanceOf(Jsonable::class, $resource);
$this->assertEquals('{"test":"value"}', $resource->toJson());
}
/**
* @covers \Engelsystem\Controllers\Api\Resources\BasicResource::toJson
*/
public function testToJsonOptions(): void
{
$resource = $this->getResource(new Collection());
$this->assertInstanceOf(Jsonable::class, $resource);
$this->assertEquals('{}', $resource->toJson(JSON_FORCE_OBJECT));
}
/**
* @covers \Engelsystem\Controllers\Api\Resources\BasicResource::__toString
*/
public function testToString(): void
{
$model = $this->getModel();
$resource = $this->getResource($model);
$this->assertInstanceOf(Stringable::class, $resource);
$this->assertEquals('{"test":"value"}', (string) $resource);
}
/**
* @covers \Engelsystem\Controllers\Api\Resources\BasicResource::collection
*/
public function testCollection(): void
{
$resource = $this->getResource(new Collection());
$modelA = $this->getModel();
$modelB = $this->getModel()->setAttribute('test', 'B');
$collection = $resource->collection([$modelA, $modelB]);
$this->assertInstanceOf(Collection::class, $collection);
$this->assertCount(2, $collection);
$this->assertInstanceOf(BasicResource::class, $collection->first());
$this->assertEquals(['test' => 'value'], $collection->first()->toArray());
$this->assertInstanceOf(BasicResource::class, $collection->last());
$this->assertEquals(['test' => 'B'], $collection->last()->toArray());
}
protected function getResource(BaseModel|Collection $model): BasicResource
{
return new class ($model) extends BasicResource {
};
}
protected function getModel(): BaseModel
{
$model = new class extends BaseModel {
};
$model->setAttribute('test', 'value');
return $model;
}
}

View File

@ -20,6 +20,11 @@ class ShiftsControllerTest extends ApiBaseControllerTest
{ {
/** /**
* @covers \Engelsystem\Controllers\Api\ShiftsController::entriesByLocation * @covers \Engelsystem\Controllers\Api\ShiftsController::entriesByLocation
* @covers \Engelsystem\Controllers\Api\Resources\ShiftResource::toArray
* @covers \Engelsystem\Controllers\Api\Resources\ShiftTypeResource::toArray
* @covers \Engelsystem\Controllers\Api\Resources\ShiftWithEntriesResource::toArray
* @covers \Engelsystem\Controllers\Api\Resources\UserResource::toArray
* @covers \Engelsystem\Controllers\Api\Resources\AngelTypeResource::toArray
* @covers \Engelsystem\Controllers\Api\ShiftsController::getNeededAngelTypes * @covers \Engelsystem\Controllers\Api\ShiftsController::getNeededAngelTypes
*/ */
public function testEntriesByLocation(): void public function testEntriesByLocation(): void
@ -83,7 +88,7 @@ class ShiftsControllerTest extends ApiBaseControllerTest
$request = new Request(); $request = new Request();
$request = $request->withAttribute('location_id', $location->id); $request = $request->withAttribute('location_id', $location->id);
$controller = new ShiftsController(new Response(), $this->url); $controller = new ShiftsController(new Response());
$response = $controller->entriesByLocation($request); $response = $controller->entriesByLocation($request);
$this->validateApiResponse('/locations/{id}/shifts', 'get', $response); $this->validateApiResponse('/locations/{id}/shifts', 'get', $response);