Skip to content

Commit deff929

Browse files
committed
feat:
api improvements: - to dto transformation for the api - allow all datatypes(except object) to be passed via api 2 new Rules fix: - inconsistent ValueCaster behaviour - InstanceOfRule did not consider the class itself, only its parents
1 parent d796496 commit deff929

19 files changed

+384
-44
lines changed

README.md

+10-1
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,15 @@ This Laravel package aims to store and manage user settings/preferences in a sim
4848
### Roadmap
4949

5050
- Additional inbuilt Custom Rules -> v2.x
51-
- Policies: ~2.0.0-beta.5
51+
- Api Enum support -> v2.2
5252
- Suggestions are welcome
5353

54+
### Known Issues
55+
`Cast::Object`: internally, laravel tries the toArray()
56+
if the object implements Arrayable, resulting in an array rather than an object as required by the validation
57+
58+
Consider sticking to Enums or Primitive casts for now
59+
5460
## Installation
5561

5662
You can install the package via composer:
@@ -416,6 +422,9 @@ implement `PreferencePolicy` and the 4 methods defined by the contract
416422

417423
off by default, enable it in the config
418424

425+
> Current limitation: it's not possible to set enums/object casts via API
426+
> Enum support planned for v2.2
427+
419428
### Anantomy:
420429

421430
'Scope': the `PreferenceableModel` Model

config/user_preference.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
'db' => [
55
'connection' => null,
66
'preferences_table_name' => 'preferences',
7-
'user_preferences_table_name' => 'user_preferences',
7+
'user_preferences_table_name' => 'users_preferences',
88
],
99
'xss_cleaning' => true,
1010
'routes' => [
1111
'enabled' => false,
1212
'middlewares' => [
13+
'web', // required for Auth::user() and policies
1314
'auth', // general middleware
1415
'user' => 'verified', // optional, scoped middleware
1516
'user.general' => 'verified' // optional, scoped & grouped middleware
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
use Matteoc99\LaravelPreference\Models\Preference;
7+
8+
return new class extends Migration {
9+
10+
public function up()
11+
{
12+
$preferenceTable = (new Preference())->getTable();
13+
14+
Schema::table($preferenceTable, function (Blueprint $table) {
15+
$table->json('allowed_values')->nullable();
16+
});
17+
}
18+
19+
public function down()
20+
{
21+
$preferenceTable = (new Preference())->getTable();
22+
23+
Schema::table($preferenceTable, function (Blueprint $table) {
24+
$table->dropColumn('allowed_values');
25+
});
26+
}
27+
};

src/Casts/ValueCaster.php

+16-5
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public function get(?Model $model, string $key, mixed $value, array $attributes)
1515
{
1616
if (is_null($value)) return null;
1717

18-
$caster = $this->getCaster($model);
18+
$caster = $this->getCaster($model, $attributes);
1919

2020
if ($caster) {
2121
return $caster->castFromString($value);
@@ -29,19 +29,30 @@ public function set(?Model $model, string $key, mixed $value, array $attributes)
2929
{
3030
if (is_null($value)) return null;
3131

32-
$caster = $this->getCaster($model);
32+
$caster = $this->getCaster($model, $attributes);
3333

3434
if ($caster) {
3535
return $caster->castToString($value);
3636
}
3737

38-
//default do nothing
3938
return $value;
4039
}
4140

42-
private function getCaster(?Model $model): CastableEnum|null
41+
private function getCaster(?Model $model, array $attributes): CastableEnum|null
4342
{
44-
$caster = $this->caster ?? $model?->cast ?? $model?->preference?->cast ?? null;
43+
if (array_key_exists('cast', $attributes)) {
44+
$caster = unserialize($attributes['cast']);
45+
} else if (is_null($model)) {
46+
$caster = $this->caster;
47+
} else {
48+
$caster = $model->cast ?? null;
49+
if (is_null($caster) && $model->isRelation('preference')) {
50+
if (!$model->relationLoaded('preference')) {
51+
$model->load('preference');
52+
}
53+
$caster = $model->preference->cast ?? null;
54+
}
55+
}
4556

4657
return $caster instanceof CastableEnum ? $caster : null;
4758
}

src/Contracts/CastableEnum.php

+9
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,13 @@ public function validation(): ValidationRule|array|string|null;
1414
public function castToString(mixed $value): string;
1515

1616
public function castFromString(string $value): mixed;
17+
18+
19+
/**
20+
* used by the Controller to cast to json
21+
* @param mixed $value
22+
*
23+
* @return array
24+
*/
25+
public function castToDto(mixed $value): array;
1726
}

src/Contracts/PreferenceableModel.php

+13
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
namespace Matteoc99\LaravelPreference\Contracts;
44

5+
use Illuminate\Auth\Access\AuthorizationException;
56
use Illuminate\Contracts\Auth\Authenticatable;
67
use Illuminate\Support\Collection;
78
use Illuminate\Validation\ValidationException;
89
use Matteoc99\LaravelPreference\Enums\PolicyAction;
910
use Matteoc99\LaravelPreference\Exceptions\PreferenceNotFoundException;
11+
use Matteoc99\LaravelPreference\Models\Preference;
1012

1113
interface PreferenceableModel
1214
{
@@ -51,6 +53,17 @@ public function setPreference(PreferenceGroup $name, mixed $value): void;
5153
*/
5254
public function getPreference(PreferenceGroup $name, mixed $default = null): mixed;
5355

56+
/**
57+
* Get a user's preference value or default if not set with no casting
58+
*
59+
* @param PreferenceGroup|Preference $preference
60+
* @param string|null $default Default value if preference not set.
61+
*
62+
* @return array
63+
* @throws AuthorizationException
64+
* @throws PreferenceNotFoundException
65+
*/
66+
public function getPreferenceDto(PreferenceGroup|Preference $preference, mixed $default = null): array;
5467

5568
/**
5669
* check if the user is authorized

src/Enums/Cast.php

+46-3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Matteoc99\LaravelPreference\Contracts\CastableEnum;
1111
use Matteoc99\LaravelPreference\Rules\InstanceOfRule;
1212
use Matteoc99\LaravelPreference\Rules\IsRule;
13+
use Matteoc99\LaravelPreference\Rules\OrRule;
1314
use UnitEnum;
1415

1516

@@ -39,15 +40,39 @@ public function validation(): ValidationRule|array|string|null
3940
self::STRING => 'string',
4041
self::BOOL => 'boolean',
4142
self::ARRAY => 'array',
42-
self::DATE, self::DATETIME => 'date',
43-
self::TIME => 'date_format:H:i',
44-
self::TIMESTAMP => 'date_format:U',
43+
self::DATE => new OrRule('date','date_format:Y-m-d', new InstanceOfRule(Carbon::class)),
44+
self::DATETIME => new OrRule('date', new InstanceOfRule(Carbon::class)),
45+
self::TIME => new OrRule('date_format:H:i', 'date_format:H:i:s', new InstanceOfRule(Carbon::class)),
46+
self::TIMESTAMP => new OrRule('date_format:U', new InstanceOfRule(Carbon::class)),
4547
self::BACKED_ENUM => new InstanceOfRule(BackedEnum::class),
4648
self::ENUM => new InstanceOfRule(UnitEnum::class),
4749
self::OBJECT => new IsRule(Type::OBJECT),
4850
};
4951
}
5052

53+
/**
54+
* @throws ValidationException
55+
*/
56+
public function castToDto(mixed $value): array
57+
{
58+
return match ($this) {
59+
self::NONE,
60+
self::BACKED_ENUM,
61+
self::ARRAY,
62+
self::ENUM,
63+
self::OBJECT => $this->valueToArray($value),
64+
self::INT,
65+
self::FLOAT,
66+
self::STRING,
67+
self::BOOL,
68+
self::TIMESTAMP,
69+
self::TIME,
70+
self::DATE,
71+
self::DATETIME => $this->valueToArray($this->castToString($value)),
72+
};
73+
}
74+
75+
5176
public function castFromString(string $value): mixed
5277
{
5378
return match ($this) {
@@ -154,4 +179,22 @@ private function ensureObject(mixed $value)
154179
}
155180
return $value;
156181
}
182+
183+
private function valueToArray(mixed $value): array
184+
{
185+
if (is_object($value) && method_exists($value, 'toArray')) {
186+
return [
187+
'value' => $value->toArray()
188+
];
189+
}
190+
191+
if (!is_array($value)) {
192+
return ['value' => is_string($value) ? $value : $this->castToString($value)];
193+
}
194+
195+
return [
196+
'value' => $value
197+
];
198+
}
199+
157200
}

src/Factory/PreferenceBuilder.php

+15
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ public function withRule(ValidationRule $rule): static
8282
return $this;
8383
}
8484

85+
public function setAllowedClasses(...$classes): static
86+
{
87+
if( in_array($this->preference->cast,[Cast::OBJECT,Cast::ENUM,Cast::BACKED_ENUM])){
88+
throw new InvalidArgumentException("Allowed classes are not supported for primitive casts");
89+
}
90+
foreach ($classes as $class) {
91+
if (!is_string($class)) {
92+
throw new InvalidArgumentException("All allowed classes must be strings.");
93+
}
94+
}
95+
96+
$this->preference->allowed_values = $classes;
97+
return $this;
98+
}
99+
85100
public function nullable(bool $nullable = true)
86101
{
87102
$this->preference->nullable = $nullable;

src/Http/Controllers/PreferenceController.php

+11-24
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,7 @@ public function __construct(Request $request)
3939
public function index(Request $request): JsonResponse
4040
{
4141
try {
42-
return $this->prepareResponse(
43-
$this->scope->getPreferences($this->group)
44-
);
42+
return response()->json($this->scope->getPreferences($this->group));
4543
} catch (Throwable $exception) {
4644
$this->handleException($exception);
4745
}
@@ -50,9 +48,7 @@ public function index(Request $request): JsonResponse
5048
public function get(Request $request): JsonResponse
5149
{
5250
try {
53-
return $this->prepareResponse(
54-
$this->scope->getPreference($this->group)
55-
);
51+
return $this->prepareResponse();
5652
} catch (Throwable $exception) {
5753
$this->handleException($exception);
5854
}
@@ -67,9 +63,7 @@ public function update(PreferenceUpdateRequest $request): JsonResponse
6763

6864
$this->scope->setPreference($this->group, $value);
6965

70-
return $this->prepareResponse(
71-
$this->scope->getPreference($this->group)
72-
);
66+
return $this->prepareResponse();
7367
} catch (Throwable $exception) {
7468
return $this->handleException($exception);
7569
}
@@ -80,9 +74,7 @@ public function delete(Request $request): JsonResponse
8074
try {
8175
$this->scope->removePreference($this->group);
8276

83-
return $this->prepareResponse(
84-
$this->scope->getPreference($this->group)
85-
);
77+
return $this->prepareResponse();
8678
} catch (Throwable $exception) {
8779
$this->handleException($exception);
8880
}
@@ -149,18 +141,13 @@ private function extractScopeAndGroup($routeName): array
149141
];
150142
}
151143

152-
private function prepareResponse(mixed $pref): JsonResponse
144+
/**
145+
* @throws AuthorizationException
146+
* @throws PreferenceNotFoundException
147+
*/
148+
private function prepareResponse(): JsonResponse
153149
{
154-
if (!empty($pref) && is_object($pref) && method_exists($pref, 'toArray')) {
155-
$pref = $pref->toArray();
156-
}
157-
158-
if (!is_array($pref)) {
159-
$pref = [
160-
'value' => $pref,
161-
];
162-
}
163-
return response()->json($pref);
150+
return response()->json($this->scope->getPreferenceDto($this->group));
164151
}
165152

166153
/**
@@ -176,7 +163,7 @@ private function handleException(Throwable|\Exception $exception)
176163
};
177164
}
178165

179-
private function clean(mixed &$value)
166+
private function clean(mixed &$value): void
180167
{
181168
if (ConfigHelper::isXssCleanEnabled()) {
182169
if (is_string($value)) {

src/Models/Preference.php

+10-7
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* @property ValidationRule|null $rule
2323
* @property PreferencePolicy|null $policy
2424
* @property mixed $default_value
25+
* @property string[] $allowed_values
2526
* @property boolean $nullable
2627
* @property Carbon $created_at
2728
* @property Carbon $updated_at
@@ -40,16 +41,18 @@ class Preference extends BaseModel
4041
'nullable',
4142
'rule',
4243
'default_value',
44+
'allowed_values',
4345
];
4446

4547
protected $casts = [
46-
'created_at' => 'datetime',
47-
'updated_at' => 'datetime',
48-
'cast' => SerializingCaster::class,
49-
'rule' => SerializingCaster::class,
50-
'policy' => SerializingCaster::class,
51-
'default_value' => ValueCaster::class,
52-
'nullable' => 'boolean',
48+
'created_at' => 'datetime',
49+
'updated_at' => 'datetime',
50+
'cast' => SerializingCaster::class,
51+
'rule' => SerializingCaster::class,
52+
'policy' => SerializingCaster::class,
53+
'default_value' => ValueCaster::class,
54+
'allowed_values' => 'array',
55+
'nullable' => 'boolean',
5356
];
5457

5558
}

src/Rules/BetweenRule.php

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace Matteoc99\LaravelPreference\Rules;
4+
5+
6+
use Closure;
7+
use Illuminate\Contracts\Validation\ValidationRule;
8+
9+
class BetweenRule implements ValidationRule
10+
{
11+
12+
public function __construct(protected float $min, protected float $max)
13+
{
14+
}
15+
16+
public function validate(string $attribute, mixed $value, Closure $fail): void
17+
{
18+
if (!is_numeric($value)) {
19+
$fail("A number is expected");
20+
}
21+
if ($value < $this->min || $value > $this->max) {
22+
$fail("The number is expected to be between $this->min and $this->max");
23+
}
24+
}
25+
}

0 commit comments

Comments
 (0)