[Symfony • Laravel] CRU*
Komparasi Create-Read-Update di Symfony dan Laravel
Yahhh topik pasaran… 🙄
Lha, Symfony dan Laravel kan juga pasaran… 😭
Iya yak 😶🌫️
CRU* di sebuah aplikasi itu ibarat resep menu utama, dan menu utama ya hampir setiap saat dipesan. Jika CRU* menurutmu begitu2 aja, apalagi dikarenakan setiap saat ketemu, coba ingat tulisan OpenAPI dan Testing kemarin. Yups, kombinasi CRU* dengan mereka mungkin bisa sedikit mengobati rasa bosanmu.
Btw CRU* itu apaan sih? 🤔
Janccckwjsitiuqybxnahgwirofl 🤬
Saat tulisan ini dibuat, Symfony telah menyediakan versi terbaru dari versi 7 yaitu versi 7.2. Jadi saya update sekalian ke versi tersebut.
Create Read Update
Di tulisan Testing kemarin kita sudah mainan bagian Read, tapi masih dalam kasus mengambil banyak data dalam format pagination. Di sini nanti kita lengkapi dengan skenario lain. Sebelum itu, kita mulai dari bagian Create terlebih dahulu.
Symfony
Mari kita tengok salah satu bagian yang sudah kita lalui sebelumnya, yaitu fixture.
<?php
namespace App\DataFixtures;
use App\Entity\Product;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class ProductFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$esCokelat = new Product();
$esCokelat->setName('Es Cokelat');
$esCokelat->setPrice('7000.00');
$manager->persist($esCokelat);
$esThaiGreenTea = new Product();
$esThaiGreenTea->setName('Es Thai Green Tea');
$esThaiGreenTea->setPrice('7000.00');
$manager->persist($esThaiGreenTea);
$esThaiTea = new Product();
$esThaiTea->setName('Es Thai Tea');
$esThaiTea->setPrice('7000.00');
$manager->persist($esThaiTea);
$manager->flush();
}
}
Melalui fixture, sebenarnya kita telah mengimplementasikan bagian Create. Tapi, di tulisan ini kita akan mengimplementasikannya dengan cara yang sedikit berbeda. Pertama, jelas kita tidak akan menggunakan fixture. Kedua, kita akan memanfaatkan service khusus sesuai skenario yang kita buat. Nyok kita mulai dari skenario testing-nya.
Buat Product\WriteTest
, sediakan testing untuk create dan validasinya.
<?php
namespace App\Tests\Product;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Repository\ProductRepository;
class WriteTest extends ApiTestCase
{
/**
* Skenario create
*/
public function testCreate(): void
{
// menentukan request/input
$request = [
'name' => 'Es Kopi',
'price' => '10000',
];
// buat request ke endpoint
$response = static::createClient()->request('POST', '/api/products', [
'json' => $request,
]);
// memastikan mengeluarkan response sukses
$this->assertResponseIsSuccessful();
// memastikan response mengandung data yang sama seperti request
$this->assertJsonContains($request);
/** @var ProductRepository */
$productRepository = static::getContainer()->get(ProductRepository::class);
// memastikan product yang dibuat tersedia di database
$product = $productRepository->findBy(array_merge($request, ['id' => $response->toArray()['id']]));
$this->assertNotNull($product);
}
/**
* Skenario validasi saat create
*
* @dataProvider createValidationProvider
*/
public function testCreateValidation(array $request, string $violation): void
{
// skenario ini kurang lebih sama seperti di ReadTest sebelumnya
// hanya request-nya menggunakan POST dan payload menggunakan json
$response = static::createClient()->request('POST', '/api/products', [
'json' => $request,
]);
$this->assertResponseIsUnprocessable();
$this->assertJsonContains([
'detail' => $violation,
]);
}
public function createValidationProvider(): \Generator
{
yield 'name.notBlank'
=> [
['price' => '10000'],
'name: This value should not be blank.',
];
yield 'name.unique'
=> [
['name' => 'Es Cokelat', 'price' => '10000'],
'name: This value is already used.',
];
yield 'price.notBlank'
=> [
['name' => 'Es Kopi'],
'price: This value should not be blank.',
];
yield 'price.greaterThan(0)'
=> [
['name' => 'Es Kopi', 'price' => '0'],
'price: This value should be greater than 0.',
];
}
}
Di ProductController
, tambahkan kode berikut untuk implementasi skenario testing-nya.
// import ini
use App\Request\ProductCreationRequest;
use App\Service\ProductCreationService;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
/**
* Creates a product resource
*/
#[Route('/api/products', methods: ['POST'])]
#[OA\Tag(name: 'Products')]
#[OA\RequestBody(
// penggunaan entity Product ditambahkan group creation
content: new Model(type: Product::class, groups: ['creation']),
)]
#[OA\Response(
response: JsonResponse::HTTP_OK,
description: 'Product resource created',
content: new Model(type: Product::class),
)]
public function create(
// service untuk pembuatan product
ProductCreationService $service,
// mapping request dari json memanfaatkan ProductCreationRequest
#[MapRequestPayload()] ProductCreationRequest $request,
): JsonResponse {
// buat product melalui ProductCreationService
$product = $service->create($request);
return $this->json($product);
}
Tambahkan group creation
untuk name
dan price
di entity Product
.
// import ini
use Symfony\Component\Serializer\Attribute\Groups;
// tambahkan group creation di name dan price
#[Groups(['creation'])]
private ?string $name = null;
#[Groups(['creation'])]
private ?string $price = null;
Sehingga payload yang muncul di OpenAPI nanti akan seperti ini. Yups, pengguna hanya diberikan akses untuk mengisi name
dan price
saja.
{
"name": "Es Cokelat",
"price": "7000.00"
}
Selanjutnya buat file src/Request/ProductCreationRequest.php
sebagai DTO untuk mapping payload. Dan kita tentukan aturan validasi sesuai skenario testing sebelumnya.
<?php
namespace App\Request;
use App\Entity\Product;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
// name harus unik, yang mengacu pada entity Product
#[UniqueEntity('name', entityClass: Product::class)]
final class ProductCreationRequest
{
public function __construct(
// tidak boleh kosong
#[Assert\NotBlank()]
public readonly ?string $name,
// tidak boleh kosong
#[Assert\NotBlank()]
// harus lebih dari 0
#[Assert\GreaterThan(0)]
public readonly ?string $price,
) {}
}
Bukankah kemarin di entity Product
, name
tidak unik ya? 🤔
Betul sekali gaes… Sebagai lapis dasar validitas data, kita tambahkan juga sifat unik pada name
di entity Product
.
// tambahkan unique true di attribute Column
#[ORM\Column(length: 50, unique: true)]
private ?string $name = null;
Jangan lupa untuk membuat migrasi, lalu menerapkannya ke database.
symfony console make:migration
# lalu
symfony console doctrine:migrations:migrate
Lanjuuuuttt… Pada repositoryProductRepository
ditambahkan dua method khusus untuk kebutuhan penyimpanan dan penghapusan product. Kedua method ini yang nanti akan dimanfaatkan oleh service untuk menangani proses bisnis aplikasi yang kita buat.
/**
* Untuk menyimpan product
*/
public function save(Product $product): Product
{
$this->getEntityManager()->persist($product);
$this->getEntityManager()->flush();
return $product;
}
/**
* Untuk menghapus product
*/
public function delete(Product $product): void
{
$this->getEntityManager()->remove($product);
$this->getEntityManager()->flush();
}
Nah, selanjutnya kita buat service seperti yang saya maksudkan di awal. Buat file src/Service/ProductCreationService.php
dengan isi seperti di bawah. Sesuai namanya, tugasnya adalah untuk melakukan pembuatan product.
<?php
namespace App\Service;
use App\Entity\Product;
use App\Repository\ProductRepository;
use App\Request\ProductCreationRequest;
final class ProductCreationService
{
public function __construct(
// ProductRepository kita injeksikan di sini
private ProductRepository $repository,
) {}
/**
* Proses membuat product
*/
public function create(ProductCreationRequest $request): Product
{
// buat object product dari entity Product
$product = new Product();
// atur nilai yang dibutuhkan dalam pembuatan product
$product->setName($request->name);
$product->setPrice($request->price);
// simpan melalui repository
$this->repository->save($product);
return $product;
}
}
Jalankan testing WriteTest
.
symfony php bin/phpunit tests/Product/WriteTest.php
Jebreeettt… ☕️
Testing App\Tests\Product\WriteTest
.... 4 / 4 (100%)
Time: 00:00.453, Memory: 34.00 MB
OK (4 tests, 9 assertions)
Lanjut bagian Read.
Waduh, gak ada istirahat nih? 😰
Ya ini, istirahat dari bagian Create 🙄
Dalam skenario API, untuk melakukan update tentunya harus diketahui data dasarnya seperti apa. Maka yang umum dilakukan adalah dengan mengambil data yang akan di-update, melalui skenario pengambilan satu data berdasarkan ID.
Tambahkan dua skenario pengambilan satu data melalui method testFindOne
dan testFindOneValidation
pada ReadTest
.
/**
* Skenario pengambilan satu product
*/
public function testFindOne()
{
// mengambil satu data product dengan id 1
$response = static::createClient()->request('GET', '/api/products/1');
// memastikan mengeluarkan response sukses
$this->assertResponseIsSuccessful();
// memastikan mengeluarkan data Es Cokelat
$this->assertJsonContains([
'id' => 1,
'name' => 'Es Cokelat',
'price' => '7000.00',
]);
}
/**
* Skenario validasi pengambilan satu product
*
* @dataProvider findOneValidationProvider
*/
public function testFindOneValidation(int $id, string $violation): void
{
$response = static::createClient()->request('GET', '/api/products/' . $id);
$this->assertResponseIsUnprocessable();
$this->assertJsonContains([
'detail' => $violation,
]);
}
public function findOneValidationProvider(): \Generator
{
yield 'id.exist'
=> [
999,
'The resource does not exist.',
];
}
Tambahkan method findOne
di ProductController
.
/**
* Retrieves a product resource
*/
#[Route('/api/products/{id}', methods: ['GET'])]
#[OA\Tag(name: 'Products')]
#[OA\Parameter(
name: 'id',
in: 'path',
description: 'Product ID',
schema: new OA\Schema(type: 'integer'),
example: 1,
)]
#[OA\Response(
response: JsonResponse::HTTP_OK,
description: 'Product resource',
content: new Model(type: Product::class),
)]
public function findOne(
// otomatis mengambil product berdasarkan id
// nilai id diambil melalui route {id}
Product $product,
): JsonResponse {
return $this->json($product);
}
Pengambilan product otomatis di atas menyisakan masalah, terutama saat env bukan production. Di production, kode di atas bisa dikatakan tidak ada masalah, karena response akan mengeluarkan struktur JSON. Tapi untuk dev atau test, error akan dikeluarkan apa adanya, sehingga skenario testing tidak akan berjalan sebagaimana yang diharapkan.
Untuk mensiasatinya, dan juga untuk menyelaraskan skenario validasi di testing, kita buat src/EventSubscriber/ExceptionSubscriber.php
untuk mengolah exception menjadi response dalam bentuk JSON.
<?php
namespace App\EventSubscriber;
use Symfony\Bridge\Doctrine\ArgumentResolver\EntityValueResolver;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
class ExceptionSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => [
['notFoundToUnprocessableEntity', 0],
],
];
}
public function notFoundToUnprocessableEntity(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
// jika terjadi NotFoundHttpException
// yang disebabkan masalah EntityValueResolver
// maka kelola menjadi response JSON
if (
$exception instanceof NotFoundHttpException
&& false !== mb_strpos($exception->getMessage(), EntityValueResolver::class)
) {
$response = new JsonResponse();
$response->setStatusCode(JsonResponse::HTTP_UNPROCESSABLE_ENTITY);
// data ini adalah data yang dikeluarkan saat NotFoundHttpException
// hanya statusnya diubah menjadi 422
$response->setData([
'type' => 'https://tools.ietf.org/html/rfc2616#section-10',
'title' => 'An error occurred',
'status' => $response->getStatusCode(),
'detail' => 'The resource does not exist.',
]);
$event->setResponse($response);
}
}
}
Selesai, jalankan ReadTest
.
symfony php bin/phpunit tests/Product/ReadTest.php
Testing App\Tests\Product\ReadTest
.......... 10 / 10 (100%)
Time: 00:00.676, Memory: 36.00 MB
OK (10 tests, 20 assertions)
Weh pengambilan satu data cuma gitu doang yak 😋
Betul, lanjut bagian Update.
Lha, gak ada istirahat lagi? 😰
Ya ini, istirahat dari bagian Read 🙄
Wah gila 🫠
Tambahkan dua skenario update melalui method testUpdate
dan testUpdateValidation
pada WriteTest
.
/**
* Skenario update
*/
public function testUpdate(): void
{
// menentukan request/input
$id = 1;
$request = [
'name' => 'Es Kopi',
'price' => '10000',
];
// buat request ke endpoint
$response = static::createClient()->request('PUT', '/api/products/' . $id, [
'json' => $request,
]);
// memastikan mengeluarkan response sukses
$this->assertResponseIsSuccessful();
// memastikan response mengandung data yang sama seperti request
$this->assertJsonContains($request);
/** @var ProductRepository */
$productRepository = static::getContainer()->get(ProductRepository::class);
// memastikan product yang diubah tersedia di database
$product = $productRepository->findBy(array_merge($request, ['id' => $id]));
$this->assertNotNull($product);
}
/**
* Skenario validasi saat update
*
* @dataProvider updateValidationProvider
*/
public function testUpdateValidation(int $id, array $request, string $violation): void
{
$response = static::createClient()->request('PUT', '/api/products/' . $id, [
'json' => $request,
]);
$this->assertResponseIsUnprocessable();
$this->assertJsonContains([
'detail' => $violation,
]);
}
public function updateValidationProvider(): \Generator
{
yield 'id.exist'
=> [
999,
[],
'The resource does not exist.',
];
yield 'name.notBlank'
=> [
1,
['price' => '10000'],
'name: This value should not be blank.',
];
yield 'name.unique'
=> [
1,
['name' => 'Es Thai Green Tea', 'price' => '10000'],
'name: This value is already used.',
];
yield 'price.notBlank'
=> [
1,
['name' => 'Es Kopi'],
'price: This value should not be blank.',
];
yield 'price.greaterThan(0)'
=> [
1,
['name' => 'Es Kopi', 'price' => '0'],
'price: This value should be greater than 0.',
];
}
Tambahkan implementasi update di ProductController
.
// import ini
use App\Request\ProductUpdateRequest;
use App\Resolver\RequestPayloadWithParamsResolver;
use App\Service\ProductUpdateService;
/**
* Updates the product resource
*/
#[Route('/api/products/{id}', methods: ['PUT'])]
#[OA\Tag(name: 'Products')]
#[OA\RequestBody(
// di sini menggunakan group update
// deklarasinya sama seperti group creation, yaitu pada name dan price
content: new Model(type: Product::class, groups: ['update']),
)]
#[OA\Response(
response: JsonResponse::HTTP_OK,
description: 'Product resource updated',
content: new Model(type: Product::class),
)]
public function update(
// seperti pada findOne, otomatis mengambil product berdasarkan id
Product $product,
// service untuk pengubahan product
ProductUpdateService $service,
// mapping request dari json memanfaatkan ProductUpdateRequest
// tapi menggunakan custom resolver, yaitu RequestPayloadWithParamsResolver
#[MapRequestPayload(
resolver: RequestPayloadWithParamsResolver::class
)] ProductUpdateRequest $request,
): JsonResponse {
$product = $service->update($product, $request);
return $this->json($product);
}
Di Symfony (sampai tulisan ini dibuat), penanganan supaya nilai dari {id}
atau apapun yang dikomposisikan di URL terpetakan juga di mapper, belum tersedia. Sehingga validasi pada ProductUpdateRequest
belum bisa terpenuhi tanpa nilai2 tersebut. Pada kasus update product, kita perlu memvalidasi agar name
tetap unik, kecuali dari product yang dimaksud. Maka dari itu peran id
menjadi sangat penting untuk merealisasikannya.
Mari kita buat custom resolver di src/Resolver/RequestPayloadWithParamsResolver.php
, isinya copas dari yang telah dibuat Symfony, lalu diberi sedikit sentuhan agar parameter yang dimaksud juga menjadi bagian dari payload. Kurang lebih akan menjadi seperti ini.
<?php
namespace App\Resolver;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpKernel\Attribute\MapUploadedFile;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Serializer\Exception\NotEncodableValueException;
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
use Symfony\Component\Serializer\Exception\UnexpectedPropertyException;
use Symfony\Component\Serializer\Exception\UnsupportedFormatException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class RequestPayloadWithParamsResolver implements ValueResolverInterface, EventSubscriberInterface
{
/**
* @see DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS
*/
private const CONTEXT_DENORMALIZE = [
'collect_denormalization_errors' => true,
];
/**
* @see DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS
*/
private const CONTEXT_DESERIALIZE = [
'collect_denormalization_errors' => true,
];
public function __construct(
private readonly SerializerInterface&DenormalizerInterface $serializer,
private readonly ?ValidatorInterface $validator = null,
private readonly ?TranslatorInterface $translator = null,
private string $translationDomain = 'validators',
) {}
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
$attribute = $argument->getAttributesOfType(MapQueryString::class, ArgumentMetadata::IS_INSTANCEOF)[0]
?? $argument->getAttributesOfType(MapRequestPayload::class, ArgumentMetadata::IS_INSTANCEOF)[0]
?? $argument->getAttributesOfType(MapUploadedFile::class, ArgumentMetadata::IS_INSTANCEOF)[0]
?? null;
if (!$attribute) {
return [];
}
if (!$attribute instanceof MapUploadedFile && $argument->isVariadic()) {
throw new \LogicException(\sprintf('Mapping variadic argument "$%s" is not supported.', $argument->getName()));
}
if ($attribute instanceof MapRequestPayload) {
if ('array' === $argument->getType()) {
if (!$attribute->type) {
throw new NearMissValueResolverException(\sprintf('Please set the $type argument of the #[%s] attribute to the type of the objects in the expected array.', MapRequestPayload::class));
}
} elseif ($attribute->type) {
throw new NearMissValueResolverException(\sprintf('Please set its type to "array" when using argument $type of #[%s].', MapRequestPayload::class));
}
}
$attribute->metadata = $argument;
return [$attribute];
}
public function onKernelControllerArguments(ControllerArgumentsEvent $event): void
{
$arguments = $event->getArguments();
foreach ($arguments as $i => $argument) {
if ($argument instanceof MapQueryString) {
$payloadMapper = $this->mapQueryString(...);
$validationFailedCode = $argument->validationFailedStatusCode;
} elseif ($argument instanceof MapRequestPayload) {
$payloadMapper = $this->mapRequestPayload(...);
$validationFailedCode = $argument->validationFailedStatusCode;
} elseif ($argument instanceof MapUploadedFile) {
$payloadMapper = $this->mapUploadedFile(...);
$validationFailedCode = $argument->validationFailedStatusCode;
} else {
continue;
}
$request = $event->getRequest();
if (!$argument->metadata->getType()) {
throw new \LogicException(\sprintf('Could not resolve the "$%s" controller argument: argument should be typed.', $argument->metadata->getName()));
}
if ($this->validator) {
$violations = new ConstraintViolationList();
try {
$payload = $payloadMapper($request, $argument->metadata, $argument);
} catch (PartialDenormalizationException $e) {
$trans = $this->translator ? $this->translator->trans(...) : fn($m, $p) => strtr($m, $p);
foreach ($e->getErrors() as $error) {
$parameters = [];
$template = 'This value was of an unexpected type.';
if ($expectedTypes = $error->getExpectedTypes()) {
$template = 'This value should be of type {{ type }}.';
$parameters['{{ type }}'] = implode('|', $expectedTypes);
}
if ($error->canUseMessageForUser()) {
$parameters['hint'] = $error->getMessage();
}
$message = $trans($template, $parameters, $this->translationDomain);
$violations->add(new ConstraintViolation($message, $template, $parameters, null, $error->getPath(), null));
}
$payload = $e->getData();
}
if (null !== $payload && !\count($violations)) {
$constraints = $argument->constraints ?? null;
if (\is_array($payload) && !empty($constraints) && !$constraints instanceof Assert\All) {
$constraints = new Assert\All($constraints);
}
$violations->addAll($this->validator->validate($payload, $constraints, $argument->validationGroups ?? null));
}
if (\count($violations)) {
throw HttpException::fromStatusCode($validationFailedCode, implode("\n", array_map(static fn($e) => $e->getMessage(), iterator_to_array($violations))), new ValidationFailedException($payload, $violations));
}
} else {
try {
$payload = $payloadMapper($request, $argument->metadata, $argument);
} catch (PartialDenormalizationException $e) {
throw HttpException::fromStatusCode($validationFailedCode, implode("\n", array_map(static fn($e) => $e->getMessage(), $e->getErrors())), $e);
}
}
if (null === $payload) {
$payload = match (true) {
$argument->metadata->hasDefaultValue() => $argument->metadata->getDefaultValue(),
$argument->metadata->isNullable() => null,
default => throw HttpException::fromStatusCode($validationFailedCode),
};
}
$arguments[$i] = $payload;
}
$event->setArguments($arguments);
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments',
];
}
private function mapQueryString(Request $request, ArgumentMetadata $argument, MapQueryString $attribute): ?object
{
$params = $request->attributes->get('_route_params');
foreach ($params as $key => $value) {
$request->query->set($key, is_numeric($value) ? (int) $value : $value);
}
if (!($data = $request->query->all()) && ($argument->isNullable() || $argument->hasDefaultValue())) {
return null;
}
return $this->serializer->denormalize($data, $argument->getType(), 'csv', $attribute->serializationContext + self::CONTEXT_DENORMALIZE + ['filter_bool' => true]);
}
private function mapRequestPayload(Request $request, ArgumentMetadata $argument, MapRequestPayload $attribute): object|array|null
{
if (null === $format = $request->getContentTypeFormat()) {
throw new UnsupportedMediaTypeHttpException('Unsupported format.');
}
if ($attribute->acceptFormat && !\in_array($format, (array) $attribute->acceptFormat, true)) {
throw new UnsupportedMediaTypeHttpException(\sprintf('Unsupported format, expects "%s", but "%s" given.', implode('", "', (array) $attribute->acceptFormat), $format));
}
if ('array' === $argument->getType() && null !== $attribute->type) {
$type = $attribute->type . '[]';
} else {
$type = $argument->getType();
}
$params = $request->attributes->get('_route_params');
if ($data = $request->request->all()) {
foreach ($params as $key => $value) {
$request->request->set($key, is_numeric($value) ? (int) $value : $value);
}
return $this->serializer->denormalize($data, $type, 'csv', $attribute->serializationContext + self::CONTEXT_DENORMALIZE + ('form' === $format ? ['filter_bool' => true] : []));
}
if ('' === ($data = $request->getContent()) && ($argument->isNullable() || $argument->hasDefaultValue())) {
return null;
}
if ('form' === $format) {
throw new BadRequestHttpException('Request payload contains invalid "form" data.');
}
try {
$decodedData = json_decode($data, true);
foreach ($params as $key => $value) {
$decodedData[$key] = is_numeric($value) ? (int) $value : $value;
}
$data = json_encode($decodedData);
return $this->serializer->deserialize($data, $type, $format, self::CONTEXT_DESERIALIZE + $attribute->serializationContext);
} catch (UnsupportedFormatException $e) {
throw new UnsupportedMediaTypeHttpException(\sprintf('Unsupported format: "%s".', $format), $e);
} catch (NotEncodableValueException $e) {
throw new BadRequestHttpException(\sprintf('Request payload contains invalid "%s" data.', $format), $e);
} catch (UnexpectedPropertyException $e) {
throw new BadRequestHttpException(\sprintf('Request payload contains invalid "%s" property.', $e->property), $e);
}
}
private function mapUploadedFile(Request $request, ArgumentMetadata $argument, MapUploadedFile $attribute): UploadedFile|array|null
{
return $request->files->get($attribute->name ?? $argument->getName(), []);
}
}
Sebenarnya saya hanya menambahkan kode berikut di setiap sebelum deserialisasi data, misalnya di method mapRequestPayload()
.
$decodedData = json_decode($data, true);
foreach ($params as $key => $value) {
// menambahkan data dari parameter ke data yang akan dideserialisasi
$decodedData[$key] = is_numeric($value) ? (int) $value : $value;
}
$data = json_encode($decodedData);
Oke, lanjut ke bagian DTO yaitu src/Request/ProductUpdateRequest.php
.
<?php
namespace App\Request;
use App\Entity\Product;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
// name harus unik, yang mengacu pada entity Product
// dengan id sebagai kriteria pengecualian
// di ProductRepository method findOneUnique
#[UniqueEntity(
fields: ['name', 'id'],
entityClass: Product::class,
repositoryMethod: 'findOneUnique',
)]
final class ProductUpdateRequest
{
public function __construct(
// digunakan untuk skenario pengecualian
public readonly int $id,
#[Assert\NotBlank()]
public readonly ?string $name,
#[Assert\NotBlank()]
#[Assert\GreaterThan(0)]
public readonly ?string $price,
) {}
}
Tambahkan method findOneUnique()
di ProductRepository
. Method inilah yang digunakan sebagai kriteria keunikan name
di validasi UniqueEntity
sebelumnya.
/**
* Mengambil satu produk yang unik
*/
public function findOneUnique(array $field)
{
$queryBuilder = $this->createQueryBuilder('p');
// keunikan product adalah dari name-nya
$queryBuilder->andWhere('p.name = :name');
$queryBuilder->setParameter('name', $field['name']);
// jika terdapat id, maka gunakan untuk pengecualian
if (isset($field['id'])) {
$queryBuilder->andWhere('p.id != :id');
$queryBuilder->setParameter('id', $field['id']);
}
$query = $queryBuilder->getQuery();
return $query->getOneOrNullResult();
}
Terakhir, buat service src/Service/ProductUpdateService.php
sebagai eksekutor pengubahan data.
<?php
namespace App\Service;
use App\Entity\Product;
use App\Repository\ProductRepository;
use App\Request\ProductUpdateRequest;
final class ProductUpdateService
{
public function __construct(
private ProductRepository $repository,
) {}
/**
* Proses mengubah product
*/
public function update(Product $product, ProductUpdateRequest $request): Product
{
// atur nilai yang dibutuhkan dalam pembuatan product
$product->setName($request->name);
$product->setPrice($request->price);
// simpan melalui repository
$this->repository->save($product);
return $product;
}
}
Selesai, sikat testing seluruhnya gaes…
php bin/phpunit
Testing
..................... 21 / 21 (100%)
Time: 00:01.225, Memory: 40.00 MB
OK (21 tests, 44 assertions)
Bagian Symfony selesai, lanjut ke bagian Laravel.
Istirahat dulu gaes 😭
Lha iya ini, istirahat dari Symfony 🙄
Laravel
Masuk ke bagian Create dulu, kita buat skenario testingnya melalui file tests/Feature/Product/WriteTest.php
.
<?php
namespace Tests\Feature\Product;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
final class WriteTest extends TestCase
{
/**
* Skenario create
*/
public function testCreate(): void
{
// menentukan request/input
$request = [
'name' => 'Es Kopi',
'price' => '10000',
];
// buat request ke endpoint
$response = $this->json('POST', '/api/products', $request);
// memastikan mengeluarkan response sukses
$response->assertSuccessful();
// memastikan response mengandung data yang sama seperti request
$response->assertJson($request);
// memastikan product yang dibuat tersedia di database
$this->assertDatabaseHas('products', array_merge($request, ['id' => $response->json()['id']]));
}
/**
* Skenario validasi saat create
*/
#[DataProvider('createValidationProvider')]
public function testCreateValidation(array $request, string $violation): void
{
$response = $this->json('POST', '/api/products', $request);
$response->assertUnprocessable();
$response->assertJson([
'message' => $violation,
]);
}
public static function createValidationProvider(): \Generator
{
yield 'name.required'
=> [
['price' => '10000'],
'The name field is required.',
];
yield 'name.unique'
=> [
['name' => 'Es Cokelat', 'price' => '10000'],
'The name has already been taken.',
];
yield 'price.required'
=> [
['name' => 'Es Kopi'],
'The price field is required.',
];
yield 'price.gt(0)'
=> [
['name' => 'Es Kopi', 'price' => '0'],
'The price field must be greater than 0.',
];
}
}
Jangan lupa, field name
di tabel products
juga harus dibuat unik. Buat migrasinya terlebih dahulu dengan perintah php artisan make:migration
, isi dengan kode berikut.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
// tambah unique di field name
$table->unique('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropUnique('name');
});
}
};
Lalu migrasikan ke database.
php artisan migrate
Lanjut ke ProductController
.
// import ini
use App\Http\Requests\ProductCreationRequest;
use App\Service\ProductCreationService;
use Illuminate\Http\Response;
/**
* Creates a product resource
*/
#[OA\Post(path: '/api/products', tags: ['Product'])]
#[OA\Tag(name: 'Products')]
#[OA\RequestBody(
content: new OA\JsonContent(ref: '#/components/schemas/ProductCreation'),
)]
#[OA\Response(
response: Response::HTTP_OK,
description: 'Product resource created',
content: new OA\JsonContent(ref: '#/components/schemas/Product'),
)]
public function create(
ProductCreationService $service,
ProductCreationRequest $request,
): JsonResponse {
$product = $service->create($request);
return response()->json($product);
}
Lihat, ada schema ProductCreation
dan Product
. Apa itu maksudnya? Nyok kita lihat lagi model Product
yang telah dibuat sebelumnya.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use OpenApi\Attributes as OA;
/**
* @property-read int $id
* @property string $name
* @property string $price
* @property-read string $created_at
* @property-read string $updated_at
*/
#[OA\Schema(
properties: [
new OA\Property(
property: 'name',
type: 'string',
example: 'Es Cokelat',
),
new OA\Property(
property: 'price',
type: 'string',
example: '7000',
),
]
)]
class Product extends Model
{
use HasFactory;
}
OA\Schema
yang menjadi attribute class Product
, akan menghasilkan schema Product
. Selain itu, nama schema juga bisa dideklarasikan manual melalui parameter schema
di dalam OA\Schema
.
Oh itu artinya ProductCreation
merupakan schema yang dideklarasikan nama schema-nya yak? 🤔
Betul gaes, aslinya sama dengan yang ada di Symfony. Hanya saja penamaan dari Nelmio dibuat secara otomatis. Kalau kita cek di hasil OpenAPI-nya, akan ada schema Product2
dst. Nah, schema tersebut merupakan schema yang dibentuk oleh penentuan group di entity Product
dan penggunaannya di ProductController
melalui Nelmio.
Jika kalian tidak berharap ada nama schema otomatis, kalian bisa tentukan di file config/packages/nelmio_api_doc.yaml
.
nelmio_api_doc:
models:
names:
- { alias: ProductCreation, type: App\Entity\Product, groups: [creation]}
- { alias: ProductUpdate, type: App\Entity\Product, groups: [update]}
Di Laravel, kita juga akan melakukan hal yang sama gaes. Caranya adalah dengan mengkomposisikan dua schema, ProductCreation
sebagai schema dasarnya, dan Product
sebagai schema utuh yang mewakili model Product
. Komposisinya kurang lebih seperti ini.
#[OA\Schema(
schema: 'ProductCreation',
properties: [
new OA\Property(
property: 'name',
type: 'string',
example: 'Es Cokelat',
),
new OA\Property(
property: 'price',
type: 'string',
example: '7000',
),
]
)]
#[OA\Schema(
properties: [
new OA\Property(
property: 'id',
type: 'integer',
example: 1,
),
],
allOf: [new OA\Schema(ref: '#/components/schemas/ProductCreation')],
)]
class Product extends Model
{
use HasFactory;
}
Saya tahu, itu artinya schema Product
dihasilkan dari schema ProductCreation
yang dikombinasikan dengan property tambahan yaitu id
ya? 😋
Hilang kan capeknya? 🙄
Masih capek, butuh istirahat 😶
Oke, kita istirahat dari OpenAPI 🙃. Lanjut kita deklarasikan router-nya di routes/api.php
.
// tambahkan di dalam group products
Route::post('/', [ProductController::class, 'create']);
Tambahkan validasi melalui app/Http/Requests/ProductCreationRequest.php
.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property string $name
* @property string $price
*/
class ProductCreationRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => [
// wajib diisi
'required',
// tipenya string
'string',
// unik di tabel products
'unique:products',
],
'price' => [
'required',
// tipenya numerik
'numeric',
// harus lebih dari 0
'gt:0',
],
];
}
}
Buat service untuk pembuatan product melalui app/Service/ProductCreationService.php
.
<?php
namespace App\Service;
use App\Http\Requests\ProductCreationRequest;
use App\Models\Product;
final class ProductCreationService
{
/**
* Proses membuat product
*/
public function create(ProductCreationRequest $request): Product
{
// buat object product dari model Product
$product = new Product();
// atur nilai yang dibutuhkan dalam pembuatan product
$product->name = $request->name;
$product->price = $request->price;
// simpan product
$product->save();
return $product;
}
}
Bentar, kok gak pakai repository? 🤔
Pertanyaan yang sulit, karena di Laravel gak ada yang nyuruh harus pakai repository gaes 🙃
Di Doctrine, repository adalah alat untuk mengambil data berdasarkan entity yang dipilih. Misalnya yang dipilih adalah entity Product
, maka repository menyediakan alat untuk pengambilan data product. Sedangkan di Eloquent, kita bisa melakukan operasional data apapun langsung dari model.
Nah, di sini kita menerapkan pendekatan yang sama baik di Symfony maupun Laravel. Entity atau model sebagai representasi dari tabel, dan repository sebagai alat bantu pengambilan data. Kalau di Laravel saya biasa menyebut repository sebagai kepanjangan fungsi model. Lalu untuk insert update delete, kita lakukan melalui service (atau action).
Hmmm… 🤔
Tenang, di serial tulisan Symfony • Laravel kita sudah menabrak banyak hal 🙃
Lanjut, jalankan testingnya.
php artisan test tests/Feature/Product/WriteTest.php
PASS Tests\Feature\Product\WriteTest
✓ create 0.35s
✓ create validation with data set "name.required" 0.03s
✓ create validation with data set "name.unique" 0.03s
✓ create validation with data set "price.required" 0.03s
✓ create validation with data set "price.gt(0)" 0.03s
Tests: 5 passed (11 assertions)
Duration: 0.55s
Lanjut bagian Read, skenario pengambilan satu data 😋
Baterai udah penuh gaes? 😶🌫️
Woke, tambahkan dua skenario pengambilan satu data melalui method testFindOne
dan testFindOneValidation
pada ReadTest
.
/**
* Skenario pengambilan satu product
*/
public function testFindOne()
{
// mengambil satu data product dengan id 1
$response = $this->json('GET', '/api/products/1');
// memastikan mengeluarkan response sukses
$response->assertSuccessful();
// memastikan mengeluarkan data Es Cokelat
$response->assertJson([
'id' => 1,
'name' => 'Es Cokelat',
'price' => '7000.00',
]);
}
/**
* Skenario validasi pengambilan satu product
*/
#[DataProvider('findOneValidationProvider')]
public function testFindOneValidation(int $id, string $violation): void
{
$response = $this->json('GET', '/api/products/' . $id);
$response->assertUnprocessable();
$response->assertJson([
'message' => $violation,
]);
}
public static function findOneValidationProvider(): \Generator
{
yield 'id.exist'
=> [
999,
'The selected product is invalid.',
];
}
Tambahkan method findOne
di ProductController
.
// import ini
use App\Models\Product;
/**
* Retrieves a product resource
*/
#[OA\Get(path: '/api/products/{product}', tags: ['Product'])]
#[OA\Tag(name: 'Products')]
#[OA\Parameter(
name: 'product',
in: 'path',
description: 'Product ID',
schema: new OA\Schema(type: 'integer'),
example: 1,
)]
#[OA\Response(
response: Response::HTTP_OK,
description: 'Product resource',
content: new OA\JsonContent(ref: '#/components/schemas/Product'),
)]
public function findOne(
// otomatis mengambil product berdasarkan id
// yang ditentukan melalui route {product}
Product $product,
): JsonResponse {
return response()->json($product);
}
Tentukan router-nya.
// import ini
use App\Util\MissingModel;
// tambahkan di dalam group products
Route::get('/{product}', [ProductController::class, 'findOne'])
->whereNumber('product')
// skenario saat product tidak ditemukan
->missing(MissingModel::jsonResponse('product'));
Sama seperti di Symfony, di Laravel kita juga akan mendesain response yang dikeluarkan saat data tidak ditemukan sama seperti saat validasi dengan aturan exists. Caranya adalah dengan menambahkan skenario missing()
memanfaatkan class MissingModel
sebagai alat bantu untuk mengeluarkan response dalam bentuk JSON. Buat file app/Util/MissingModel.php
dengan kode seperti berikut.
<?php
namespace App\Util;
use Illuminate\Http\Response;
final class MissingModel
{
/**
* Mengeluarkan response dalam bentuk JSON
*/
public static function jsonResponse(string $attribute): callable
{
// message memanfaatkan translasi dari validation.exists
// dengan attribute sesuai yang ditentukan
return fn() => response()->json([
'message' => trans('validation.exists', ['attribute' => $attribute]),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
Selesai, jalankan ReadTest
.
php artisan test tests/Feature/Product/ReadTest.php
PASS Tests\Feature\Product\ReadTest
✓ find many with data set "default" 0.83s
✓ find many with data set "page size 20" 0.03s
✓ find many with data set "page 2" 0.03s
✓ find many with data set "filter by name = cokelat" 0.03s
✓ find many with data set "filter by name = thai" 0.03s
✓ find many with data set "filter by name = teh" 0.03s
✓ find many validation with data set "page size.min:10" 0.03s
✓ find many validation with data set "page.min:1" 0.03s
✓ find one 0.03s
✓ find one validation with data set "id.exist" 0.03s
Tests: 10 passed (20 assertions)
Duration: 1.19s
Lanjut bagian Update 😋
Wah semangat gila 🥳
Tambahkan skenario berikut di WriteTest
.
/**
* Skenario update
*/
public function testUpdate(): void
{
// menentukan request/input
$id = 1;
$request = [
'name' => 'Es Kopi',
'price' => '10000',
];
// buat request ke endpoint
$response = $this->json('PUT', '/api/products/' . $id, $request);
// memastikan mengeluarkan response sukses
$response->assertSuccessful();
// memastikan response mengandung data yang sama seperti request
$response->assertJson($request);
// memastikan product yang diubah tersedia di database
$this->assertDatabaseHas('products', array_merge($request, ['id' => $id]));
}
/**
* Skenario validasi saat update
*/
#[DataProvider('updateValidationProvider')]
public function testUpdateValidation(int $id, array $request, string $violation): void
{
$response = $this->json('PUT', '/api/products/' . $id, $request);
$response->assertUnprocessable();
$response->assertJson([
'message' => $violation,
]);
}
public static function updateValidationProvider(): \Generator
{
yield 'id.exist'
=> [
999,
[],
'The selected product is invalid.',
];
yield 'name.required'
=> [
1,
['price' => '10000'],
'The name field is required.',
];
yield 'name.unique'
=> [
1,
['name' => 'Es Thai Green Tea', 'price' => '10000'],
'The name has already been taken.',
];
yield 'price.required'
=> [
1,
['name' => 'Es Kopi'],
'The price field is required.',
];
yield 'price.gt(0)'
=> [
1,
['name' => 'Es Kopi', 'price' => '0'],
'The price field must be greater than 0.',
];
}
Tambahkan implementasi update di ProductController
.
// import ini
use App\Http\Requests\ProductUpdateRequest;
use App\Service\ProductUpdateService;
/**
* Updates the product resource
*/
#[OA\Put(path: '/api/products/{product}', tags: ['Product'])]
#[OA\Tag(name: 'Products')]
#[OA\Parameter(
name: 'product',
in: 'path',
description: 'Product ID',
schema: new OA\Schema(type: 'integer'),
example: 1,
)]
#[OA\RequestBody(
// menggunakan schema ProductUpdate
content: new OA\JsonContent(ref: '#/components/schemas/ProductUpdate'),
)]
#[OA\Response(
response: Response::HTTP_OK,
description: 'Product resource created',
content: new OA\JsonContent(ref: '#/components/schemas/Product'),
)]
public function update(
// seperti pada findOne, otomatis mengambil product berdasarkan id
Product $product,
ProductUpdateService $service,
ProductUpdateRequest $request,
): JsonResponse {
$product = $service->update($product, $request);
return response()->json($product);
}
Atur router-nya.
// tambahkan di group products
Route::put('/{product}', [ProductController::class, 'update'])
->whereNumber('product')
->missing(MissingModel::jsonResponse('product'));
Tambahkan schema ProductUpdate
di model Product
, menggunakan schema ProductCreation
.
#[OA\Schema(
schema: 'ProductUpdate',
allOf: [new OA\Schema(ref: '#/components/schemas/ProductCreation')],
)]
Buat form request app/Http/Requests/ProductUpdateRequest.php
.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @property string $name
* @property string $price
*/
class ProductUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
// harus unik, dengan id sebagai kriteria pengecualian
Rule::unique('products')->ignore($this->route('id')),
],
'price' => [
'required',
'numeric',
'gt:0',
],
];
}
}
Selanjutnya buat app/Service/ProductUpdateService.php
.
<?php
namespace App\Service;
use App\Http\Requests\ProductUpdateRequest;
use App\Models\Product;
final class ProductUpdateService
{
/**
* Proses mengubah product
*/
public function update(Product $product, ProductUpdateRequest $request): Product
{
// atur nilai yang dibutuhkan dalam pembuatan product
$product->name = $request->name;
$product->price = $request->price;
// simpan product
$product->save();
return $product;
}
}
Selesai, jalankan testing seluruhnya…
php artisan test
PASS Tests\Feature\Product\ReadTest
✓ find many with data set "default" 0.54s
✓ find many with data set "page size 20" 0.03s
✓ find many with data set "page 2" 0.03s
✓ find many with data set "filter by name = cokelat" 0.03s
✓ find many with data set "filter by name = thai" 0.03s
✓ find many with data set "filter by name = teh" 0.03s
✓ find many validation with data set "page size.min:10" 0.03s
✓ find many validation with data set "page.min:1" 0.03s
✓ find one 0.03s
✓ find one validation with data set "id.exist" 0.03s
PASS Tests\Feature\Product\WriteTest
✓ create 0.04s
✓ create validation with data set "name.required" 0.03s
✓ create validation with data set "name.unique" 0.03s
✓ create validation with data set "price.required" 0.03s
✓ create validation with data set "price.gt(0)" 0.03s
✓ update 0.03s
✓ update validation with data set "id.exist" 0.03s
✓ update validation with data set "name.required" 0.03s
✓ update validation with data set "name.unique" 0.03s
✓ update validation with data set "price.required" 0.03s
✓ update validation with data set "price.gt(0)" 0.03s
Tests: 21 passed (44 assertions)
Duration: 1.25s
Woke, lanjut ke skenario Delete 🙃
Istirahat dulu dong 😭
Eee pura2 semangat tadi? 🙄
Niatnya biar cepet selesai 😭
Lha terus Delete-nya kapan? 🙄
Kapan2 aja, kan judulnya sudah jadi CRU* 😭
Perasaan awal saya nulis ada D-nya, kamu yang hapus ya? 😤
Maapppp 😭
Yaudah, bikin kesimpulan 😤
Asiiiiikkkkk 😋
Kesimpulan
Dari yang saya cermati, pembagian tugas menjadi perhatian yang serius di sini. Kedua framework rasanya membebaskan pilihan kepada pengguna. Sehingga rasanya tidak mungkin kita juga bebas melakukan apapun hanya berdasarkan apa yang mereka sediakan. Terutama, karena kita juga butuh “framework buatan sendiri” dalam pekerjaan se-hari2, yang mampu bertahan dalam jangka panjang. Itulah mengapa, jika terbiasa dengan repository, lakukan dengan repository. Jika terbiasa dengan service, lakukan dengan service, di framework apapun. Yaaaa terlebih prinsip seperti itu telah banyak diadopsi dan mampu bertahan hingga saat ini.
Saya sendiri gak pernah yang gimana2 gaes, cukup melakukan dengan biasa. Kalau ada sesuatu yang rasanya butuh diatur, ya diatur. Ya begitulah kebiasaan umum di kehidupan ini.
Jadi, kapan kita lanjut ke bagian Delete? 🙃
Kan sudah kesimpulan 😭