Skip to content

Commit 9fefb3f

Browse files
Support for add/replace of multiple attributes (limosa-io#89)
1 parent 23a2eed commit 9fefb3f

File tree

9 files changed

+153
-21
lines changed

9 files changed

+153
-21
lines changed

database/factories/UserFactory.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
return [
77
// 'username' => $faker->userName,
88
'email' => $faker->unique()->email,
9+
'formatted' => $faker->name,
910
'name' => $faker->name,
10-
'password'=>'test'
11+
'password'=>'test',
12+
'active' => false
1113
];
1214
});

src/Attribute/Attribute.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ public function applyComparison(Builder &$query, Path $path, $parentAttribute =
277277

278278
public function add($value, Model &$object)
279279
{
280-
new SCIMException(sprintf('Write is not implemented for "%s"', $this->getFullKey()));
280+
throw new SCIMException(sprintf('Write is not implemented for "%s"', $this->getFullKey()));
281281
}
282282

283283
public function replace($value, Model &$object, Path $path = null)

src/Attribute/Complex.php

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
namespace ArieTimmerman\Laravel\SCIMServer\Attribute;
44

55
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
6+
use ArieTimmerman\Laravel\SCIMServer\Parser\Parser;
67
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
78
use Illuminate\Database\Eloquent\Builder;
89
use Illuminate\Database\Eloquent\Model;
910

1011
class Complex extends AbstractComplex
1112
{
12-
13+
1314
/**
1415
* @return string[]
1516
*/
@@ -18,9 +19,9 @@ public function getSchemas()
1819
return collect($this->getSchemaNodes())->map(fn ($element) => $element->name)->values()->toArray();
1920
}
2021

21-
22+
2223
public function read(&$object, array $attributes = []): ?AttributeValue
23-
{
24+
{
2425
if (!($this instanceof Schema) && $this->parent != null && !empty($attributes) && !in_array($this->name, $attributes) && !in_array($this->getFullKey(), $attributes)) {
2526
return null;
2627
}
@@ -33,8 +34,8 @@ protected function doRead(&$object, $attributes = [])
3334
{
3435
$result = [];
3536
foreach ($this->subAttributes as $attribute) {
36-
if(($r = $attribute->read($object, $attributes)) != null){
37-
if(config('scim.omit_null_values') && $r->value === null){
37+
if (($r = $attribute->read($object, $attributes)) != null) {
38+
if (config('scim.omit_null_values') && $r->value === null) {
3839
continue;
3940
}
4041
$result[$attribute->name] = $r->value;
@@ -47,7 +48,7 @@ public function patch($operation, $value, Model &$object, Path $path = null, $re
4748
{
4849
$this->dirty = true;
4950

50-
if($this->mutability == 'readOnly'){
51+
if ($this->mutability == 'readOnly') {
5152
// silently ignore
5253
return;
5354
}
@@ -112,21 +113,44 @@ public function replace($value, Model &$object, Path $path = null, $removeIfNotS
112113
$match = false;
113114
$this->dirty = true;
114115

115-
if($this->mutability == 'readOnly'){
116+
if ($this->mutability == 'readOnly') {
116117
// silently ignore
117118
return;
118119
}
119120

120-
// if there is no path, keys of value are attribute names
121+
// if there is no path, keys of value are attribute paths
121122
foreach ($value as $key => $v) {
122123
if (is_numeric($key)) {
123124
throw new SCIMException('Invalid key: ' . $key . ' for complex object ' . $this->getFullKey());
124125
}
125126

126-
$attribute = $this->getSubNode($key);
127-
if ($attribute != null) {
128-
$attribute->replace($v, $object, $path);
127+
$subNode = null;
128+
129+
// if path contains : it is a schema node
130+
if (strpos($key, ':') !== false) {
131+
$subNode = $this->getSubNode($key);
129132
$match = true;
133+
} else {
134+
$path = Parser::parse($key);
135+
136+
if ($path->isNotEmpty()) {
137+
$attributeNames = $path->getAttributePathAttributes();
138+
$path = $path->shiftAttributePathAttributes();
139+
$sub = $attributeNames[0] ?? $path->getAttributePath()?->path?->schema;
140+
$subNode = $this->getSubNode($attributeNames[0] ?? $path->getAttributePath()?->path?->schema);
141+
$match = true;
142+
}
143+
}
144+
145+
if ($match) {
146+
$newValue = $v;
147+
if ($path->isNotEmpty()) {
148+
$newValue = [
149+
implode('.', $path->getAttributePathAttributes()) => $v
150+
];
151+
}
152+
153+
$subNode->replace($newValue, $object, $path);
130154
}
131155
}
132156

@@ -148,10 +172,55 @@ public function replace($value, Model &$object, Path $path = null, $removeIfNotS
148172
}
149173
}
150174

175+
public function add($value, Model &$object)
176+
{
177+
$match = false;
178+
$this->dirty = true;
179+
180+
if ($this->mutability == 'readOnly') {
181+
// silently ignore
182+
return;
183+
}
184+
185+
// keys of value are attribute names
186+
foreach ($value as $key => $v) {
187+
if (is_numeric($key)) {
188+
throw new SCIMException('Invalid key: ' . $key . ' for complex object ' . $this->getFullKey());
189+
}
190+
191+
$path = Parser::parse($key);
192+
193+
if ($path->isNotEmpty()) {
194+
$attributeNames = $path->getAttributePathAttributes();
195+
$path = $path->shiftAttributePathAttributes();
196+
$subNode = $this->getSubNode($attributeNames[0]);
197+
$match = true;
198+
199+
$newValue = $v;
200+
if ($path->isNotEmpty()) {
201+
$newValue = [
202+
implode('.', $path->getAttributePathAttributes()) => $v
203+
];
204+
}
205+
206+
$subNode->add($newValue, $object);
207+
}
208+
}
209+
210+
// if this is the root, we may also check the schema nodes
211+
if (!$match && $this->parent == null) {
212+
foreach ($this->subAttributes as $attribute) {
213+
if ($attribute instanceof Schema) {
214+
$attribute->add($value, $object);
215+
}
216+
}
217+
}
218+
}
219+
151220

152221
public function remove($value, Model &$object, string $path = null)
153222
{
154-
if($this->mutability == 'readOnly'){
223+
if ($this->mutability == 'readOnly') {
155224
// silently ignore
156225
return;
157226
}
@@ -186,8 +255,8 @@ public function getSortAttributeByPath(Path $path)
186255
return $result;
187256
}
188257

189-
public function applyComparison(Builder &$query, Path $path, $parentAttribute = null){
190-
258+
public function applyComparison(Builder &$query, Path $path, $parentAttribute = null)
259+
{
191260
if ($path != null && $path->isNotEmpty()) {
192261
$attributeNames = $path->getValuePathAttributes();
193262

@@ -223,7 +292,6 @@ public function applyComparison(Builder &$query, Path $path, $parentAttribute =
223292
}
224293
}
225294
}
226-
227295
}
228296

229297
/**

src/Parser/Path.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public function __toString()
9292
public function isNotEmpty(){
9393
return
9494
!empty($this->getAttributePathAttributes()) ||
95-
!empty($this->getAttributePathAttributes()) ||
95+
!empty($this->getValuePathAttributes()) ||
9696
$this->getValuePathFilter() != null;
9797
}
9898
}

src/SCIMConfig.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ protected function doRead(&$object, $attributes = [])
7676
new Meta('Users'),
7777
(new AttributeSchema(Schema::SCHEMA_USER, true))->withSubAttributes(
7878
eloquent('userName', 'name')->ensure('required'),
79-
complex('name')->withSubAttributes(eloquent('formatted', 'name')),
79+
eloquent('active')->ensure('boolean')->default(false),
80+
complex('name')->withSubAttributes(eloquent('formatted')),
8081
eloquent('password')->ensure('nullable'),
8182
(new class ('emails') extends Complex {
8283
protected function doRead(&$object, $attributes = [])

tests/BasicTest.php

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public function testGetGroupsAttribute()
6868
$response->assertJsonStructure([
6969
'Resources' => [
7070
'*' => [
71-
71+
7272
]
7373
]
7474
]);
@@ -259,6 +259,58 @@ public function testPatch()
259259
$this->assertEquals('something@example.com', $json['urn:ietf:params:scim:schemas:core:2.0:User']['emails'][0]['value']);
260260
}
261261

262+
public function testPatchMultiple()
263+
{
264+
$response = $this->patch('/scim/v2/Users/2', [
265+
"schemas" => [
266+
"urn:ietf:params:scim:api:messages:2.0:PatchOp",
267+
],
268+
"Operations" => [[
269+
"op" => "add",
270+
"value" => [
271+
"userName" => "johndoe9858",
272+
"name.formatted" => "John",
273+
"active" => false
274+
]
275+
]]
276+
]);
277+
278+
$response->assertStatus(200);
279+
280+
$json = $response->json();
281+
282+
$this->assertArrayHasKey('urn:ietf:params:scim:schemas:core:2.0:User', $json);
283+
$this->assertEquals('johndoe9858', $json['urn:ietf:params:scim:schemas:core:2.0:User']['userName']);
284+
$this->assertEquals('John', $json['urn:ietf:params:scim:schemas:core:2.0:User']['name']['formatted']);
285+
$this->assertFalse($json['urn:ietf:params:scim:schemas:core:2.0:User']['active']);
286+
}
287+
288+
public function testPatchMultipleReplace()
289+
{
290+
$response = $this->patch('/scim/v2/Users/2', [
291+
"schemas" => [
292+
"urn:ietf:params:scim:api:messages:2.0:PatchOp",
293+
],
294+
"Operations" => [[
295+
"op" => "replace",
296+
"value" => [
297+
"userName" => "johndoe9858",
298+
"name.formatted" => "John",
299+
"active" => true
300+
]
301+
]]
302+
]);
303+
304+
$response->assertStatus(200);
305+
306+
$json = $response->json();
307+
308+
$this->assertArrayHasKey('urn:ietf:params:scim:schemas:core:2.0:User', $json);
309+
$this->assertEquals('johndoe9858', $json['urn:ietf:params:scim:schemas:core:2.0:User']['userName']);
310+
$this->assertEquals('John', $json['urn:ietf:params:scim:schemas:core:2.0:User']['name']['formatted']);
311+
$this->assertTrue($json['urn:ietf:params:scim:schemas:core:2.0:User']['active']);
312+
}
313+
262314
public function testPatchUsername()
263315
{
264316
$response = $this->patch('/scim/v2/Users/4', [

tests/CustomSchemaTest.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ public function testPost()
5656
"urn:ietf:params:scim:schemas:core:2.0:User" => [
5757
"userName" => "Dr. Marie Jo",
5858
"password" => "Password123",
59-
'employeeNumber' => '123',
6059
"emails" => [
6160
[
6261
"value" => "mariejo@example.com",

tests/Model/User.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
class User extends \Illuminate\Foundation\Auth\User
66
{
7+
8+
protected $casts = [
9+
'active' => 'boolean',
10+
];
11+
712
public function groups()
813
{
914
return $this->belongsToMany(Group::class);

tests/TestCase.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ protected function setUp(): void
4343
$table->timestamps();
4444
});
4545

46+
Schema::table('users', function (Blueprint $table) {
47+
$table->string('formatted')->nullable();
48+
$table->boolean('active')->default(false);
49+
});
50+
4651
$this->withFactories(realpath(dirname(__DIR__) . '/database/factories'));
4752

4853
\ArieTimmerman\Laravel\SCIMServer\RouteProvider::routes();

0 commit comments

Comments
 (0)