Skip to content

Commit dd15c73

Browse files
committed
Add support for <any />
1 parent 6e95c7e commit dd15c73

14 files changed

+527
-23
lines changed

src/Encoder/AnyElementEncoder.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Soap\Encoding\Encoder;
4+
5+
use RuntimeException;
6+
use Soap\Encoding\Xml\Node\Element;
7+
use Soap\Encoding\Xml\Node\ElementList;
8+
use Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader;
9+
use Soap\Engine\Metadata\Model\Property;
10+
use Soap\Engine\Metadata\Model\Type;
11+
use VeeWee\Reflecta\Iso\Iso;
12+
use VeeWee\Reflecta\Lens\Lens;
13+
use function is_array;
14+
use function is_string;
15+
use function Psl\Dict\diff_by_key;
16+
use function Psl\Iter\first;
17+
use function Psl\Iter\reduce;
18+
use function Psl\Str\join;
19+
use function Psl\Type\string;
20+
use function Psl\Type\vec;
21+
22+
/**
23+
* @implements XmlEncoder<array|string|null, string>
24+
*
25+
* @psalm-import-type LookupArray from DocumentToLookupArrayReader
26+
*
27+
* @template-implements Feature\ProvidesObjectDecoderLens<LookupArray, ElementList>
28+
*/
29+
final class AnyElementEncoder implements Feature\ListAware, Feature\OptionalAware, Feature\ProvidesObjectDecoderLens, XmlEncoder
30+
{
31+
/**
32+
* This lens will be used to decode XML into an 'any' property.
33+
* It will contain all the XML tags available in the object that is surrounding the 'any' property.
34+
* Properties that are already known by the object, will be omitted.
35+
*
36+
* @return Lens<LookupArray, ElementList>
37+
*/
38+
public static function createObjectDecoderLens(Type $parentType, Property $currentProperty): Lens
39+
{
40+
$omittedKeys = reduce(
41+
$parentType->getProperties(),
42+
static fn (array $omit, Property $property): array => [
43+
...$omit,
44+
...($property->getName() !== $currentProperty->getName() ? [$property->getName()] : []),
45+
],
46+
[]
47+
);
48+
49+
/**
50+
* @param LookupArray $data
51+
* @return LookupArray
52+
*/
53+
$omit = static fn (array $data): array => diff_by_key($data, array_flip($omittedKeys));
54+
55+
/** @var Lens<LookupArray, ElementList> */
56+
return new Lens(
57+
/**
58+
* @psalm-suppress MixedArgumentTypeCoercion - Psalm gets confused about the result of omit.
59+
* @param LookupArray $data
60+
*/
61+
static fn (array $data): ElementList => ElementList::fromLookupArray($omit($data)),
62+
static fn (array $_data, ElementList $_value): never => throw new RuntimeException('Readonly lens')
63+
);
64+
}
65+
66+
/**
67+
* @return Iso<array|string|null, string>
68+
*/
69+
public function iso(Context $context): Iso
70+
{
71+
$meta = $context->type->getMeta();
72+
$isNullable = $meta->isNullable()->unwrapOr(false);
73+
$isList = $meta->isList()->unwrapOr(false);
74+
75+
return new Iso(
76+
static fn (string|array|null $raw): string => match (true) {
77+
is_string($raw) => $raw,
78+
is_array($raw) => join(vec(string())->assert($raw), ''),
79+
default => '',
80+
},
81+
/**
82+
* @psalm-suppress DocblockTypeContradiction - Psalm gets confused about the return type of first() in default case.
83+
* @psalm-return null|array<array-key, string>|string
84+
*/
85+
static fn (ElementList|string $xml): mixed => match(true) {
86+
is_string($xml) => $xml,
87+
$isList && !$xml->hasElements() => [],
88+
$isNullable && !$xml->hasElements() => null,
89+
$isList => $xml->traverse(static fn (Element $element) => $element->value()),
90+
default => first($xml->elements())?->value(),
91+
}
92+
);
93+
}
94+
}

src/Encoder/ErrorHandlingEncoder.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
* @template-covariant TXml
1313
*
1414
* @implements XmlEncoder<TData, TXml>
15-
*
15+
* @implements Feature\DecoratingEncoder<TData, TXml>
1616
*/
17-
final class ErrorHandlingEncoder implements XmlEncoder
17+
final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, XmlEncoder
1818
{
1919
/**
2020
* @param XmlEncoder<TData, TXml> $encoder
@@ -24,6 +24,14 @@ public function __construct(
2424
) {
2525
}
2626

27+
/**
28+
* @return XmlEncoder<TData, TXml>
29+
*/
30+
public function decoratedEncoder(): XmlEncoder
31+
{
32+
return $this->encoder;
33+
}
34+
2735
/**
2836
* @return Iso<TData, TXml>
2937
*/
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Soap\Encoding\Encoder\Feature;
4+
5+
use Soap\Encoding\Encoder\XmlEncoder;
6+
7+
/**
8+
* @template-covariant TData
9+
* @template-covariant TXml
10+
*/
11+
interface DecoratingEncoder
12+
{
13+
/**
14+
* @return XmlEncoder<TData, TXml>
15+
*/
16+
public function decoratedEncoder(): XmlEncoder;
17+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Soap\Encoding\Encoder\Feature;
4+
5+
use Soap\Engine\Metadata\Model\Property;
6+
use Soap\Engine\Metadata\Model\Type;
7+
use VeeWee\Reflecta\Lens\Lens;
8+
9+
/**
10+
* When an encoder implements this feature interface, it knows how to create a lens that will be applied on the parent data that is being decoded.
11+
*
12+
* @template-covariant S
13+
* @template-covariant A
14+
*/
15+
interface ProvidesObjectDecoderLens
16+
{
17+
/**
18+
* @return Lens<S, A>
19+
*/
20+
public static function createObjectDecoderLens(Type $parentType, Property $currentProperty): Lens;
21+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Soap\Encoding\Encoder\Feature;
4+
5+
use Soap\Engine\Metadata\Model\Property;
6+
use Soap\Engine\Metadata\Model\Type;
7+
use VeeWee\Reflecta\Lens\Lens;
8+
9+
/**
10+
* When an encoder implements this feature interface, it knows how to create a lens that will be applied on the parent data that is being encoded.
11+
*
12+
* @template-covariant S
13+
* @template-covariant A
14+
*/
15+
interface ProvidesObjectEncoderLens
16+
{
17+
/**
18+
* @return Lens<S, A>
19+
*/
20+
public static function createObjectEncoderLens(Type $parentType, Property $currentProperty): Lens;
21+
}

src/Encoder/ObjectAccess.php

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Soap\Encoding\Normalizer\PhpPropertyNameNormalizer;
77
use Soap\Encoding\TypeInference\ComplexTypeBuilder;
88
use Soap\Engine\Metadata\Model\Property;
9+
use Soap\Engine\Metadata\Model\Type;
910
use Soap\Engine\Metadata\Model\TypeMeta;
1011
use VeeWee\Reflecta\Iso\Iso;
1112
use VeeWee\Reflecta\Lens\Lens;
@@ -47,17 +48,21 @@ public static function forContext(Context $context): self
4748
$isAnyPropertyQualified = false;
4849

4950
foreach ($sortedProperties as $property) {
50-
$typeMeta = $property->getType()->getMeta();
51+
$propertyType = $property->getType();
52+
$propertyTypeMeta = $propertyType->getMeta();
53+
$propertyContext = $context->withType($propertyType);
5154
$name = $property->getName();
5255
$normalizedName = PhpPropertyNameNormalizer::normalize($name);
5356

54-
$shouldLensBeOptional = self::shouldLensBeOptional($typeMeta);
57+
$encoder = $context->registry->detectEncoderForContext($propertyContext);
58+
$shouldLensBeOptional = self::shouldLensBeOptional($propertyTypeMeta);
5559
$normalizedProperties[$normalizedName] = $property;
56-
$encoderLenses[$normalizedName] = $shouldLensBeOptional ? optional(property($normalizedName)) : property($normalizedName);
57-
$decoderLenses[$normalizedName] = $shouldLensBeOptional ? optional(index($name)) : index($name);
58-
$isos[$normalizedName] = self::grabIsoForProperty($context, $property);
5960

60-
$isAnyPropertyQualified = $isAnyPropertyQualified || $typeMeta->isQualified()->unwrapOr(false);
61+
$encoderLenses[$normalizedName] = self::createEncoderLensForType($shouldLensBeOptional, $normalizedName, $encoder, $type, $property);
62+
$decoderLenses[$normalizedName] = self::createDecoderLensForType($shouldLensBeOptional, $name, $encoder, $type, $property);
63+
$isos[$normalizedName] = $encoder->iso($propertyContext);
64+
65+
$isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false);
6166
}
6267

6368
return new self(
@@ -69,6 +74,46 @@ public static function forContext(Context $context): self
6974
);
7075
}
7176

77+
/**
78+
* @return Lens<object, mixed>
79+
*/
80+
private static function createEncoderLensForType(
81+
bool $shouldLensBeOptional,
82+
string $normalizedName,
83+
XmlEncoder $encoder,
84+
Type $type,
85+
Property $property,
86+
): Lens {
87+
$lens = match (true) {
88+
$encoder instanceof Feature\DecoratingEncoder => self::createEncoderLensForType($shouldLensBeOptional, $normalizedName, $encoder->decoratedEncoder(), $type, $property),
89+
$encoder instanceof Feature\ProvidesObjectEncoderLens => $encoder::createObjectEncoderLens($type, $property),
90+
default => property($normalizedName)
91+
};
92+
93+
/** @var Lens<object, mixed> */
94+
return $shouldLensBeOptional ? optional($lens) : $lens;
95+
}
96+
97+
/**
98+
* @return Lens<array, mixed>
99+
*/
100+
private static function createDecoderLensForType(
101+
bool $shouldLensBeOptional,
102+
string $name,
103+
XmlEncoder $encoder,
104+
Type $type,
105+
Property $property,
106+
): Lens {
107+
$lens = match(true) {
108+
$encoder instanceof Feature\DecoratingEncoder => self::createDecoderLensForType($shouldLensBeOptional, $name, $encoder->decoratedEncoder(), $type, $property),
109+
$encoder instanceof Feature\ProvidesObjectDecoderLens => $encoder::createObjectDecoderLens($type, $property),
110+
default => index($name),
111+
};
112+
113+
/** @var Lens<array, mixed> */
114+
return $shouldLensBeOptional ? optional($lens) : $lens;
115+
}
116+
72117
private static function shouldLensBeOptional(TypeMeta $meta): bool
73118
{
74119
if ($meta->isNullable()->unwrapOr(false)) {
@@ -84,15 +129,4 @@ private static function shouldLensBeOptional(TypeMeta $meta): bool
84129

85130
return false;
86131
}
87-
88-
/**
89-
* @return Iso<mixed, string>
90-
*/
91-
private static function grabIsoForProperty(Context $context, Property $property): Iso
92-
{
93-
$propertyContext = $context->withType($property->getType());
94-
95-
return $context->registry->detectEncoderForContext($propertyContext)
96-
->iso($propertyContext);
97-
}
98132
}

src/EncoderRegistry.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
use Psl\Collection\MutableMap;
77
use Soap\Encoding\ClassMap\ClassMapCollection;
8+
use Soap\Encoding\Encoder\AnyElementEncoder;
89
use Soap\Encoding\Encoder\Context;
910
use Soap\Encoding\Encoder\ElementEncoder;
1011
use Soap\Encoding\Encoder\EncoderDetector;
@@ -106,7 +107,6 @@ public static function default(): self
106107
$qNameFormatter($xsd, 'decimal') => new SimpleType\FloatTypeEncoder(),
107108

108109
// Scalar:
109-
$qNameFormatter($xsd, 'any') => new SimpleType\ScalarTypeEncoder(),
110110
$qNameFormatter($xsd, 'anyType') => new SimpleType\ScalarTypeEncoder(),
111111
$qNameFormatter($xsd, 'anyXML') => new SimpleType\ScalarTypeEncoder(),
112112
$qNameFormatter($xsd, 'anySimpleType') => new SimpleType\ScalarTypeEncoder(),
@@ -159,6 +159,9 @@ public static function default(): self
159159

160160
// Apache Map
161161
$qNameFormatter(ApacheMapDetector::NAMESPACE, 'Map') => new SoapEnc\ApacheMapEncoder(),
162+
163+
// Special XSD cases
164+
$qNameFormatter($xsd, 'any') => new AnyElementEncoder(),
162165
])
163166
);
164167
}

src/Xml/Node/ElementList.php

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@
33

44
namespace Soap\Encoding\Xml\Node;
55

6+
use Closure;
67
use DOMElement;
8+
use Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader;
9+
use Stringable;
710
use VeeWee\Xml\Dom\Document;
11+
use function Psl\Iter\reduce;
12+
use function Psl\Vec\map;
813
use function VeeWee\Xml\Dom\Locator\Element\children as readChildren;
914

10-
final class ElementList
15+
/**
16+
* @psalm-import-type LookupArray from DocumentToLookupArrayReader
17+
*/
18+
final class ElementList implements Stringable
1119
{
1220
/** @var list<Element> */
1321
private array $elements;
@@ -20,6 +28,36 @@ public function __construct(Element ...$elements)
2028
$this->elements = $elements;
2129
}
2230

31+
/**
32+
* Can be used to parse a nested array structure to a full flattened ElementList.
33+
*
34+
* @see \Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader::__invoke
35+
*
36+
* @param LookupArray $data
37+
*/
38+
public static function fromLookupArray(array $data): self
39+
{
40+
return new self(
41+
...reduce(
42+
$data,
43+
/**
44+
* @param list<Element> $elements
45+
*
46+
* @return list<Element>
47+
*/
48+
static fn (array $elements, string|Element|ElementList $value) => [
49+
...$elements,
50+
...match(true) {
51+
$value instanceof Element => [$value],
52+
$value instanceof ElementList => $value->elements(),
53+
default => [], // Strings are considered simpleTypes - not elements
54+
}
55+
],
56+
[],
57+
)
58+
);
59+
}
60+
2361
/**
2462
* @param non-empty-string $xml
2563
*/
@@ -48,4 +86,29 @@ public function elements(): array
4886
{
4987
return $this->elements;
5088
}
89+
90+
public function hasElements(): bool
91+
{
92+
return (bool) $this->elements;
93+
}
94+
95+
/**
96+
* @template R
97+
* @param Closure(Element): R $mapper
98+
* @return list<R>
99+
*/
100+
public function traverse(Closure $mapper): array
101+
{
102+
return map($this->elements, $mapper);
103+
}
104+
105+
public function value(): string
106+
{
107+
return implode('', $this->traverse(static fn (Element $element): string => $element->value()));
108+
}
109+
110+
public function __toString()
111+
{
112+
return $this->value();
113+
}
51114
}

0 commit comments

Comments
 (0)