Skip to content

Commit ac8fc93

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

File tree

10 files changed

+445
-8
lines changed

10 files changed

+445
-8
lines changed

src/Encoder/AnyElementEncoder.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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+
final class AnyElementEncoder implements Feature\ListAware, Feature\OptionalAware, XmlEncoder
28+
{
29+
/**
30+
* This lens will be used to decode XML into an 'any' property.
31+
* It will contain all the XML tags available in the object that is surrounding the 'any' property.
32+
* Properties that are already known by the object, will be omitted.
33+
*
34+
* @return Lens<LookupArray, ElementList>
35+
*/
36+
public static function createDecoderLens(Type $type): Lens
37+
{
38+
$omittedKeys = reduce(
39+
$type->getProperties(),
40+
static fn (array $omit, Property $property): array => [
41+
...$omit,
42+
...($property->getName() !== 'any' ? [$property->getName()] : []),
43+
],
44+
[]
45+
);
46+
47+
/**
48+
* @param LookupArray $data
49+
* @return LookupArray
50+
*/
51+
$omit = static fn (array $data): array => diff_by_key($data, array_flip($omittedKeys));
52+
53+
/** @var Lens<LookupArray, ElementList> */
54+
return new Lens(
55+
/**
56+
* @psalm-suppress MixedArgumentTypeCoercion - Psalm gets confused about the result of omit.
57+
* @param LookupArray $data
58+
*/
59+
static fn (array $data): ElementList => ElementList::fromLookupArray($omit($data)),
60+
static fn (array $_data, ElementList $_value): never => throw new RuntimeException('Readonly lens')
61+
);
62+
}
63+
64+
/**
65+
* @return Iso<array|string|null, string>
66+
*/
67+
public function iso(Context $context): Iso
68+
{
69+
$meta = $context->type->getMeta();
70+
$isNullable = $meta->isNullable()->unwrapOr(false);
71+
$isList = $meta->isList()->unwrapOr(false);
72+
73+
return new Iso(
74+
static fn (string|array|null $raw): string => match (true) {
75+
is_string($raw) => $raw,
76+
is_array($raw) => join(vec(string())->assert($raw), ''),
77+
default => '',
78+
},
79+
/**
80+
* @psalm-suppress DocblockTypeContradiction - Psalm gets confused about the return type of first() in default case.
81+
* @psalm-return null|array<array-key, string>|string
82+
*/
83+
static fn (ElementList|string $xml): mixed => match(true) {
84+
is_string($xml) => $xml,
85+
$isList && !$xml->hasElements() => [],
86+
$isNullable && !$xml->hasElements() => null,
87+
$isList => $xml->traverse(static fn (Element $element) => $element->value()),
88+
default => first($xml->elements())?->value(),
89+
}
90+
);
91+
}
92+
}

src/Encoder/ObjectAccess.php

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@
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;
11+
use Soap\Engine\Metadata\Model\XsdType;
12+
use Soap\WsdlReader\Metadata\Predicate\IsOfType;
13+
use Soap\Xml\Xmlns;
1014
use VeeWee\Reflecta\Iso\Iso;
1115
use VeeWee\Reflecta\Lens\Lens;
16+
use function Psl\Type\non_empty_string;
1217
use function Psl\Vec\sort_by;
1318
use function VeeWee\Reflecta\Lens\index;
1419
use function VeeWee\Reflecta\Lens\optional;
@@ -47,17 +52,18 @@ public static function forContext(Context $context): self
4752
$isAnyPropertyQualified = false;
4853

4954
foreach ($sortedProperties as $property) {
50-
$typeMeta = $property->getType()->getMeta();
55+
$propertyType = $property->getType();
56+
$propertyTypeMeta = $propertyType->getMeta();
5157
$name = $property->getName();
5258
$normalizedName = PhpPropertyNameNormalizer::normalize($name);
5359

54-
$shouldLensBeOptional = self::shouldLensBeOptional($typeMeta);
60+
$shouldLensBeOptional = self::shouldLensBeOptional($propertyTypeMeta);
5561
$normalizedProperties[$normalizedName] = $property;
56-
$encoderLenses[$normalizedName] = $shouldLensBeOptional ? optional(property($normalizedName)) : property($normalizedName);
57-
$decoderLenses[$normalizedName] = $shouldLensBeOptional ? optional(index($name)) : index($name);
62+
$encoderLenses[$normalizedName] = self::createEncoderLensForType($shouldLensBeOptional, $normalizedName);
63+
$decoderLenses[$normalizedName] = self::createDecoderLensForType($shouldLensBeOptional, $name, $type, $propertyType);
5864
$isos[$normalizedName] = self::grabIsoForProperty($context, $property);
5965

60-
$isAnyPropertyQualified = $isAnyPropertyQualified || $typeMeta->isQualified()->unwrapOr(false);
66+
$isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false);
6167
}
6268

6369
return new self(
@@ -69,6 +75,35 @@ public static function forContext(Context $context): self
6975
);
7076
}
7177

78+
/**
79+
* @return Lens<object, mixed>
80+
*/
81+
private static function createEncoderLensForType(
82+
bool $shouldLensBeOptional,
83+
string $normalizedName
84+
): Lens {
85+
$lens = property($normalizedName);
86+
87+
return $shouldLensBeOptional ? optional($lens) : $lens;
88+
}
89+
90+
/**
91+
* @return Lens<array, mixed>
92+
*/
93+
private static function createDecoderLensForType(
94+
bool $shouldLensBeOptional,
95+
string $name,
96+
Type $type,
97+
XsdType $propertyType,
98+
): Lens {
99+
$lens = match(true) {
100+
(new IsOfType(non_empty_string()->assert(Xmlns::xsd()->value()), 'any'))($propertyType) => AnyElementEncoder::createDecoderLens($type),
101+
default => index($name),
102+
};
103+
104+
return $shouldLensBeOptional ? optional($lens) : $lens;
105+
}
106+
72107
private static function shouldLensBeOptional(TypeMeta $meta): bool
73108
{
74109
if ($meta->isNullable()->unwrapOr(false)) {

src/EncoderRegistry.php

Lines changed: 4 additions & 0 deletions
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;
@@ -159,6 +160,9 @@ public static function default(): self
159160

160161
// Apache Map
161162
$qNameFormatter(ApacheMapDetector::NAMESPACE, 'Map') => new SoapEnc\ApacheMapEncoder(),
163+
164+
// Special XSD cases
165+
$qNameFormatter($xsd, 'any') => new AnyElementEncoder(),
162166
])
163167
);
164168
}

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
}

src/Xml/Reader/DocumentToLookupArrayReader.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
use Soap\Encoding\Xml\Node\ElementList;
1010
use function VeeWee\Xml\Dom\Predicate\is_element;
1111

12+
/**
13+
* @psalm-type LookupArray = array<string, string|Element|ElementList>
14+
*/
1215
final class DocumentToLookupArrayReader
1316
{
1417
/**
15-
* @return array<string, string|Element|ElementList>
18+
* @return LookupArray
1619
*/
1720
public function __invoke(Element $xml): array
1821
{
@@ -55,7 +58,7 @@ public function __invoke(Element $xml): array
5558
/** @var \iterable<DOMAttr> $attributes */
5659
$attributes = $root->attributes;
5760
foreach ($attributes as $attribute) {
58-
$key = $attribute->localName ?? 'unkown';
61+
$key = $attribute->localName ?? 'unknown';
5962
$nodes[$key] = $attribute->value;
6063
}
6164

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Soap\Encoding\Test\PhpCompatibility\Implied;
5+
6+
use PHPUnit\Framework\Attributes\CoversClass;
7+
use Soap\Encoding\Decoder;
8+
use Soap\Encoding\Driver;
9+
use Soap\Encoding\Encoder;
10+
use Soap\Encoding\Test\PhpCompatibility\AbstractCompatibilityTests;
11+
12+
#[CoversClass(Driver::class)]
13+
#[CoversClass(Encoder::class)]
14+
#[CoversClass(Decoder::class)]
15+
#[CoversClass(Encoder\AnyElementEncoder::class)]
16+
final class ImpliedSchema005Test extends AbstractCompatibilityTests
17+
{
18+
protected string $schema = <<<EOXML
19+
<element name="testType">
20+
<complexType>
21+
<sequence>
22+
<element name="customerName" type="xsd:string" />
23+
<element name="customerEmail" type="xsd:string" />
24+
<any processContents="strict" minOccurs="0" maxOccurs="3" />
25+
</sequence>
26+
</complexType>
27+
</element>
28+
EOXML;
29+
protected string $type = 'type="tns:testType"';
30+
31+
protected function calculateParam(): mixed
32+
{
33+
return (object)[
34+
'customerName' => 'John Doe',
35+
'customerEmail' => 'john@doe.com',
36+
'any' => [
37+
'<hello>world</hello>',
38+
'<hello>moon</hello>',
39+
],
40+
];
41+
}
42+
43+
protected function expectXml(): string
44+
{
45+
return <<<XML
46+
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tns="http://test-uri/"
47+
xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
48+
xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/">
49+
<SOAP-ENV:Body SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
50+
<tns:test>
51+
<testParam xsi:type="tns:testType">
52+
<customerName xsi:type="xsd:string">John Doe</customerName>
53+
<customerEmail xsi:type="xsd:string">john@doe.com</customerEmail>
54+
<hello>world</hello>
55+
<hello>moon</hello>
56+
</testParam>
57+
</tns:test>
58+
</SOAP-ENV:Body>
59+
</SOAP-ENV:Envelope>
60+
XML;
61+
}
62+
}

0 commit comments

Comments
 (0)