Skip to content

Commit

Permalink
Merge pull request #4 from totem-it/master-feat-resource(#SAM-337)
Browse files Browse the repository at this point in the history
Master feat resource(#sam 337)
  • Loading branch information
rudashi authored Oct 9, 2024
2 parents 566b12f + 0093583 commit b924091
Show file tree
Hide file tree
Showing 11 changed files with 385 additions and 2 deletions.
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,13 @@ Remember to put repository in a composer.json
## Usage

Functionalities are organized into packages within the src/Bundles folder:

- [Middleware](#middleware)
- [LocalizationMiddleware](#LocalizationMiddleware)
- [ForceJsonMiddleware](#ForceJsonMiddleware)
- [Resource](#resource)
- [ApiCollection](#ApiCollection)
- [ApiResource](#ApiResource)
- [ValueObject](#ValueObject)

---
Expand Down Expand Up @@ -66,12 +70,29 @@ example of use in provider:

---

## Resource

### ApiCollection

Used to return a collection of models in an API response. Extends the ResourceCollection by providing additional information
to the API response

### ApiResource

extends JsonResource

- `whenHasAttribute()` Checks if the resource has the specified attribute.
- `noContent()` - Allows the response to be returned with an HTTP 204 (No Content) status code.
---


## ValueObject

Useful in value objects (VO) or data transfer objects (DTOs) where you often need to validate and parse input data before
using it. It provides a simple and reusable way to handle common parsing scenarios.
Useful in value objects (VO) or data transfer objects (DTOs) where you often need to validate and parse input data
before using it. It provides a simple and reusable way to handle common parsing scenarios.

Parse the property to a trimmed string or returns null.

```php
ParseValueObject::trimOrNull(' some text ');
// `some text`
Expand All @@ -81,6 +102,7 @@ ParseValueObject::trimOrNull(null);
```

Parse the property to an int or returns null.

```php
ParseValueObject::intOrNull('123');
// 123
Expand Down
18 changes: 18 additions & 0 deletions src/Bundles/Resource/AdditionalResourceData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Bundles\Resource;

trait AdditionalResourceData
{
/**
* @return array{apiVersion: string}
*/
public function with($request): array
{
return [
'apiVersion' => config('app.api'),
];
}
}
21 changes: 21 additions & 0 deletions src/Bundles/Resource/ApiCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Bundles\Resource;

use Illuminate\Http\Resources\Json\ResourceCollection;

class ApiCollection extends ResourceCollection
{
use AdditionalResourceData;

public function __construct($resource, ?string $collects = null)
{
if ($collects !== null) {
$this->collects = $collects;
}

parent::__construct($resource);
}
}
61 changes: 61 additions & 0 deletions src/Bundles/Resource/ApiResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Bundles\Resource;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue;
use Symfony\Component\HttpFoundation\Response;

/**
* @property bool $preserveKeys
*
* @method static ApiCollection collection(mixed $resource)
*/
class ApiResource extends JsonResource
{
use AdditionalResourceData;

public static function noContent(): self
{
return new self(true);
}

/**
* @return array<array-key, mixed>
*/
public function toArray(Request $request): array
{
if (is_bool($this->resource)) {
return [];
}

return parent::toArray($request);
}

public function withResponse($request, $response): void
{
if (is_bool($this->resource)) {
$response->setStatusCode(Response::HTTP_NO_CONTENT);
}
}

protected static function newCollection($resource): ApiCollection
{
return new ApiCollection($resource, static::class);
}

protected function whenHasAttribute(string $attribute, $value = null, $default = null): mixed
{
if (array_key_exists($attribute, $this->resource->getAttributes())) {
return $value instanceof Closure
? $value()
: $this->resource->getAttribute($attribute);
}

return func_num_args() === 3 ? value($default) : new MissingValue();
}
}
7 changes: 7 additions & 0 deletions src/SamSkeletonServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,11 @@ public function boot(): void
{
$this->app['router']->prependMiddlewareToGroup('api', ForceJsonMiddleware::class);
}

public function register(): void
{
$this->app['config']->set([
'app.api' => env('APP_API', '1.0.0'),
]);
}
}
40 changes: 40 additions & 0 deletions tests/Resource/AdditionalResourceDataTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Tests\Resource;

use Illuminate\Http\Request;
use Totem\SamSkeleton\Bundles\Resource\AdditionalResourceData;
use Totem\SamSkeleton\Bundles\Resource\ApiResource;
use Totem\SamSkeleton\Tests\TestCase;

uses(TestCase::class);

covers(AdditionalResourceData::class);

beforeEach(function () {
$this->request = Request::create('/');
$this->resource = new ApiResource([]);
});

it('returns the api version', function (): void {
$response = $this->resource->with($this->request);

expect($response)->toBe(['apiVersion' => config('app.api')]);
});

it('includes apiVersion in the response', function () {
$response = $this->resource->toResponse($this->request);

expect($response->getData()->apiVersion)->toBe(config('app.api'));
});

it('uses api version from config', function () {
expect(config('app.api'))->toBe(config('app.api'));

config(['app.api' => '1.2']);
$response = $this->resource->with($this->request);

expect($response)->toBe(['apiVersion' => '1.2']);
});
56 changes: 56 additions & 0 deletions tests/Resource/ApiCollectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Tests\Resource;

use Illuminate\Http\Request;
use Totem\SamSkeleton\Bundles\Resource\ApiCollection;
use Totem\SamSkeleton\Bundles\Resource\ApiResource;
use Totem\SamSkeleton\Tests\TestCase;

uses(TestCase::class);

covers(ApiCollection::class);

beforeEach(function () {
$this->resource = [fake()->words()];
$this->request = Request::create('/');
});

it('sets the collects property if provided', function (): void {
$collection = new ApiCollection($this->resource, ApiResource::class);

expect($collection)
->toBeInstanceOf(ApiCollection::class)
->collects->toBe(ApiResource::class)
->collection->each(
fn ($item, $index) => $item
->toBeInstanceOf(ApiResource::class)
->resource->toBe($this->resource[$index])
);
});

test('collect property returns null when collects is null', function (): void {
$collection = new ApiCollection($this->resource, null);

expect($collection->collects)->toBeNull();
});

it('returns an empty collection when resource is empty array', function (): void {
$collection = new ApiCollection([], ApiResource::class);

expect($collection)
->collects->toBe(ApiResource::class)
->collection->toBeEmpty();
});

it('returns response with correct data', function (): void {
$collection = new ApiCollection($this->resource, ApiResource::class);

$response = $collection->toResponse($this->request);

expect($response->getData())
->data->toMatchArray($this->resource)
->apiVersion->toBe(config('app.api'));
});
93 changes: 93 additions & 0 deletions tests/Resource/ApiResourceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Tests\Resource;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\MissingValue;
use Symfony\Component\HttpFoundation\Response;
use Totem\SamSkeleton\Bundles\Resource\ApiCollection;
use Totem\SamSkeleton\Bundles\Resource\ApiResource;
use Totem\SamSkeleton\Tests\TestCase;

uses(TestCase::class);

covers(ApiResource::class);

beforeEach(function () {
$this->request = Request::create('/');
$this->resource = [fake()->word() => fake()->word()];
});

it('returns the resource as an array', function (): void {
$resource = new ApiResource($this->resource);

$array = $resource->toArray($this->request);

expect($array)->toBe($this->resource);
});

it('returns an empty array when resource is boolean true', function (): void {
$resource = new ApiResource(true);

$array = $resource->toArray($this->request);

expect($array)->toBe([]);
});

it('returns an instance of ApiResource with true value', function (): void {
$response = ApiResource::noContent();

expect($response)
->toBeInstanceOf(ApiResource::class)
->resource->toBeTrue();
});

it('returns HTTP no content status', function (): void {
$resource = ApiResource::noContent();

$response = $resource->toResponse($this->request);

expect($response)
->toBeInstanceOf(JsonResponse::class)
->getStatusCode()->toBe(Response::HTTP_NO_CONTENT);
});

it('returns ApiCollection class', function (): void {
$resource = fake()->words();

$apiCollection = ApiResource::collection($resource);

expect($apiCollection)
->toBeInstanceOf(ApiCollection::class)
->toHaveCount(3)
->each(fn ($item, $index) => $item->toBeInstanceOf(ApiResource::class)->resource->toBe($resource[$index]));
});

test('optional attributes are handled correctly', function (): void {
$resource = new FixtureApiResource(new FixtureModel());

expect($resource->toArray($this->request))
->sequence(
fn ($item, $key) => $key->toBe('id')->and($item)->toBe(5),
fn ($item, $key) => $key->toBe('first')->and($item)->toBeTrue(),
fn ($item, $key) => $key->toBe('second')->and($item)->toBeTrue(),
fn ($item, $key) => $key->toBe('third')->and($item)->toBe('override value'),
fn ($item, $key) => $key->toBe('fourth')->and($item)->toBeTrue(),
fn ($item, $key) => $key->toBe('fifth')->and($item)->toBeTrue(),
fn ($item, $key) => $key->toBe('sixth')->and($item)->toBeTrue(),
fn ($item, $key) => $key->toBe('seventh')->and($item)->toBeInstanceOf(MissingValue::class),
fn ($item, $key) => $key->toBe('eighth')->and($item)->toBe('default'),
);
});

it('returns response with correct data', function (): void {
$resource = new ApiResource($this->resource);
$response = $resource->toResponse($this->request);

expect($response->getData())
->data->toMatchArray($this->resource)
->apiVersion->toBe(config('app.api'));
});
30 changes: 30 additions & 0 deletions tests/Resource/FixtureApiResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Totem\SamSkeleton\Tests\Resource;

use Illuminate\Http\Request;
use Totem\SamSkeleton\Bundles\Resource\ApiResource;

class FixtureApiResource extends ApiResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'first' => $this->whenHasAttribute('is_published'),
'second' => $this->whenHasAttribute('is_published', 'override value'),
'third' => $this->whenHasAttribute('is_published', function () {
return 'override value';
}),
'fourth' => $this->whenHasAttribute('is_published', $this->is_published, 'default'),
'fifth' => $this->whenHasAttribute('is_published', $this->is_published, function () {
return 'default';
}),
'sixth' => $this->whenHasAttribute('is_published', $this->is_published, fn () => 'default'),
'seventh' => $this->whenHasAttribute('other_attribute'),
'eighth' => $this->whenHasAttribute('other_attribute', null, 'default'),
];
}
}
Loading

0 comments on commit b924091

Please sign in to comment.