Skip to content

Commit b0976a0

Browse files
authored
Merge pull request #30 from WordPress/dto-serlialization
Adds DTO array transformation and JSON serialization
2 parents ba3b4cd + 19b4692 commit b0976a0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+3447
-224
lines changed
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WordPress\AiClient\Common;
6+
7+
use InvalidArgumentException;
8+
use JsonSerializable;
9+
use stdClass;
10+
use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface;
11+
use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface;
12+
13+
/**
14+
* Abstract base class for all Data Value Objects in the AI Client.
15+
*
16+
* This abstract class consolidates the common functionality needed by all
17+
* data transfer objects:
18+
* - Array transformation for data manipulation
19+
* - JSON schema support for validation and documentation
20+
* - JSON serialization with proper empty object handling
21+
*
22+
* All DTOs in the AI Client should extend this class to ensure
23+
* consistent behavior across the codebase.
24+
*
25+
* @since n.e.x.t
26+
*
27+
* @template TArrayShape of array<string, mixed>
28+
* @implements WithArrayTransformationInterface<TArrayShape>
29+
*/
30+
abstract class AbstractDataValueObject implements
31+
WithArrayTransformationInterface,
32+
WithJsonSchemaInterface,
33+
JsonSerializable
34+
{
35+
/**
36+
* Validates that required keys exist in the array data.
37+
*
38+
* @since n.e.x.t
39+
*
40+
* @param TArrayShape $data The array data to validate.
41+
* @param string[] $requiredKeys The keys that must be present.
42+
* @throws InvalidArgumentException If any required key is missing.
43+
*/
44+
protected static function validateFromArrayData(array $data, array $requiredKeys): void
45+
{
46+
$missingKeys = [];
47+
48+
foreach ($requiredKeys as $key) {
49+
if (!array_key_exists($key, $data)) {
50+
$missingKeys[] = $key;
51+
}
52+
}
53+
54+
if (!empty($missingKeys)) {
55+
throw new InvalidArgumentException(
56+
sprintf(
57+
'%s::fromArray() missing required keys: %s',
58+
static::class,
59+
implode(', ', $missingKeys)
60+
)
61+
);
62+
}
63+
}
64+
65+
/**
66+
* Converts the object to a JSON-serializable format.
67+
*
68+
* This method uses the toArray() method and then processes the result
69+
* based on the JSON schema to ensure proper object representation for
70+
* empty arrays.
71+
*
72+
* @since n.e.x.t
73+
*
74+
* @return mixed The JSON-serializable representation.
75+
*/
76+
#[\ReturnTypeWillChange]
77+
public function jsonSerialize()
78+
{
79+
$data = $this->toArray();
80+
$schema = static::getJsonSchema();
81+
82+
return $this->convertEmptyArraysToObjects($data, $schema);
83+
}
84+
85+
/**
86+
* Recursively converts empty arrays to stdClass objects where the schema expects objects.
87+
*
88+
* @since n.e.x.t
89+
*
90+
* @param mixed $data The data to process.
91+
* @param array<mixed, mixed> $schema The JSON schema for the data.
92+
* @return mixed The processed data.
93+
*/
94+
private function convertEmptyArraysToObjects($data, array $schema)
95+
{
96+
// If data is an empty array and schema expects object, convert to stdClass
97+
if (is_array($data) && empty($data) && isset($schema['type']) && $schema['type'] === 'object') {
98+
return new stdClass();
99+
}
100+
101+
// If data is an array with content, recursively process nested structures
102+
if (is_array($data)) {
103+
// Handle object properties
104+
if (isset($schema['properties']) && is_array($schema['properties'])) {
105+
foreach ($data as $key => $value) {
106+
if (isset($schema['properties'][$key]) && is_array($schema['properties'][$key])) {
107+
$data[$key] = $this->convertEmptyArraysToObjects($value, $schema['properties'][$key]);
108+
}
109+
}
110+
}
111+
112+
// Handle array items
113+
if (isset($schema['items']) && is_array($schema['items'])) {
114+
foreach ($data as $index => $item) {
115+
$data[$index] = $this->convertEmptyArraysToObjects($item, $schema['items']);
116+
}
117+
}
118+
119+
// Handle oneOf schemas - just use the first one
120+
if (isset($schema['oneOf']) && is_array($schema['oneOf'])) {
121+
foreach ($schema['oneOf'] as $possibleSchema) {
122+
if (is_array($possibleSchema)) {
123+
return $this->convertEmptyArraysToObjects($data, $possibleSchema);
124+
}
125+
}
126+
}
127+
}
128+
129+
return $data;
130+
}
131+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace WordPress\AiClient\Common\Contracts;
6+
7+
/**
8+
* Interface for objects that support array transformation.
9+
*
10+
* @since n.e.x.t
11+
*
12+
* @template TArrayShape of array<string, mixed>
13+
*/
14+
interface WithArrayTransformationInterface
15+
{
16+
/**
17+
* Converts the object to an array representation.
18+
*
19+
* @since n.e.x.t
20+
*
21+
* @return TArrayShape The array representation.
22+
*/
23+
public function toArray(): array;
24+
25+
/**
26+
* Creates an instance from array data.
27+
*
28+
* @since n.e.x.t
29+
*
30+
* @param TArrayShape $array The array data.
31+
* @return self<TArrayShape> The created instance.
32+
*/
33+
public static function fromArray(array $array): self;
34+
}

src/Files/DTO/File.php

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
namespace WordPress\AiClient\Files\DTO;
66

7-
use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface;
7+
use InvalidArgumentException;
8+
use RuntimeException;
9+
use WordPress\AiClient\Common\AbstractDataValueObject;
810
use WordPress\AiClient\Files\Enums\FileTypeEnum;
911
use WordPress\AiClient\Files\ValueObjects\MimeType;
1012

@@ -15,9 +17,22 @@
1517
* and handles them appropriately.
1618
*
1719
* @since n.e.x.t
20+
*
21+
* @phpstan-type FileArrayShape array{
22+
* fileType: string,
23+
* url?: string,
24+
* mimeType: string,
25+
* base64Data?: string
26+
* }
27+
*
28+
* @extends AbstractDataValueObject<FileArrayShape>
1829
*/
19-
class File implements WithJsonSchemaInterface
30+
class File extends AbstractDataValueObject
2031
{
32+
public const KEY_FILE_TYPE = 'fileType';
33+
public const KEY_MIME_TYPE = 'mimeType';
34+
public const KEY_URL = 'url';
35+
public const KEY_BASE64_DATA = 'base64Data';
2136
/**
2237
* @var MimeType The MIME type of the file.
2338
*/
@@ -335,46 +350,94 @@ public static function getJsonSchema(): array
335350
'oneOf' => [
336351
[
337352
'properties' => [
338-
'fileType' => [
353+
self::KEY_FILE_TYPE => [
339354
'type' => 'string',
340355
'const' => FileTypeEnum::REMOTE,
341356
'description' => 'The file type.',
342357
],
343-
'mimeType' => [
358+
self::KEY_MIME_TYPE => [
344359
'type' => 'string',
345360
'description' => 'The MIME type of the file.',
346361
'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\\-\\^_+.]*\\/[a-zA-Z0-9]'
347362
. '[a-zA-Z0-9!#$&\\-\\^_+.]*$',
348363
],
349-
'url' => [
364+
self::KEY_URL => [
350365
'type' => 'string',
351366
'format' => 'uri',
352367
'description' => 'The URL to the remote file.',
353368
],
354369
],
355-
'required' => ['fileType', 'mimeType', 'url'],
370+
'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_URL],
356371
],
357372
[
358373
'properties' => [
359-
'fileType' => [
374+
self::KEY_FILE_TYPE => [
360375
'type' => 'string',
361376
'const' => FileTypeEnum::INLINE,
362377
'description' => 'The file type.',
363378
],
364-
'mimeType' => [
379+
self::KEY_MIME_TYPE => [
365380
'type' => 'string',
366381
'description' => 'The MIME type of the file.',
367382
'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\\-\\^_+.]*\\/[a-zA-Z0-9]'
368383
. '[a-zA-Z0-9!#$&\\-\\^_+.]*$',
369384
],
370-
'base64Data' => [
385+
self::KEY_BASE64_DATA => [
371386
'type' => 'string',
372387
'description' => 'The base64-encoded file data.',
373388
],
374389
],
375-
'required' => ['fileType', 'mimeType', 'base64Data'],
390+
'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_BASE64_DATA],
376391
],
377392
],
378393
];
379394
}
395+
396+
/**
397+
* {@inheritDoc}
398+
*
399+
* @since n.e.x.t
400+
*
401+
* @return FileArrayShape
402+
*/
403+
public function toArray(): array
404+
{
405+
$data = [
406+
self::KEY_FILE_TYPE => $this->fileType->value,
407+
self::KEY_MIME_TYPE => $this->getMimeType(),
408+
];
409+
410+
if ($this->url !== null) {
411+
$data[self::KEY_URL] = $this->url;
412+
} elseif (!$this->fileType->isRemote() && $this->base64Data !== null) {
413+
$data[self::KEY_BASE64_DATA] = $this->base64Data;
414+
} else {
415+
throw new RuntimeException(
416+
'File requires either url or base64Data. This should not be a possible condition.'
417+
);
418+
}
419+
420+
return $data;
421+
}
422+
423+
/**
424+
* {@inheritDoc}
425+
*
426+
* @since n.e.x.t
427+
*/
428+
public static function fromArray(array $array): self
429+
{
430+
static::validateFromArrayData($array, [self::KEY_FILE_TYPE]);
431+
432+
// Check which properties are set to determine how to construct the File
433+
$mimeType = $array[self::KEY_MIME_TYPE] ?? null;
434+
435+
if (isset($array[self::KEY_URL])) {
436+
return new self($array[self::KEY_URL], $mimeType);
437+
} elseif (isset($array[self::KEY_BASE64_DATA])) {
438+
return new self($array[self::KEY_BASE64_DATA], $mimeType);
439+
} else {
440+
throw new InvalidArgumentException('File requires either url or base64Data.');
441+
}
442+
}
380443
}

0 commit comments

Comments
 (0)