From 9eee0e734bc0210c1547a8e66a1a168f04f3ff75 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 6 Dec 2024 11:28:35 +0100 Subject: [PATCH] Add support for --- composer.json | 6 +- src/Encoder/AnyElementEncoder.php | 92 +++++++++++++++++++ src/Encoder/ErrorHandlingEncoder.php | 12 ++- src/Encoder/Feature/DecoratingEncoder.php | 17 ++++ .../Feature/ProvidesObjectDecoderLens.php | 21 +++++ .../Feature/ProvidesObjectEncoderLens.php | 21 +++++ src/Encoder/ObjectAccess.php | 68 ++++++++++---- src/EncoderRegistry.php | 5 +- src/Xml/Node/ElementList.php | 65 ++++++++++++- .../Reader/DocumentToLookupArrayReader.php | 7 +- .../Implied/ImpliedSchema005Test.php | 62 +++++++++++++ .../Implied/ImpliedSchema006Test.php | 60 ++++++++++++ .../Implied/ImpliedSchema007Test.php | 50 ++++++++++ .../Implied/ImpliedSchema008Test.php | 52 +++++++++++ tests/Unit/Xml/Node/ElementListTest.php | 16 ++++ 15 files changed, 528 insertions(+), 26 deletions(-) create mode 100644 src/Encoder/AnyElementEncoder.php create mode 100644 src/Encoder/Feature/DecoratingEncoder.php create mode 100644 src/Encoder/Feature/ProvidesObjectDecoderLens.php create mode 100644 src/Encoder/Feature/ProvidesObjectEncoderLens.php create mode 100644 tests/PhpCompatibility/Implied/ImpliedSchema005Test.php create mode 100644 tests/PhpCompatibility/Implied/ImpliedSchema006Test.php create mode 100644 tests/PhpCompatibility/Implied/ImpliedSchema007Test.php create mode 100644 tests/PhpCompatibility/Implied/ImpliedSchema008Test.php diff --git a/composer.json b/composer.json index 221170e..da6a2de 100644 --- a/composer.json +++ b/composer.json @@ -22,12 +22,12 @@ "require": { "php": "~8.2.0 || ~8.3.0 || ~8.4.0", "azjezz/psl": "^3.0", - "veewee/reflecta": "~0.10", + "veewee/reflecta": "~0.11", "veewee/xml": "^3.3", - "php-soap/engine": "^2.13", + "php-soap/engine": "^2.14", "php-soap/wsdl": "^1.12", "php-soap/xml": "^1.8", - "php-soap/wsdl-reader": "~0.18" + "php-soap/wsdl-reader": "~0.20" }, "require-dev": { "vimeo/psalm": "^5.26", diff --git a/src/Encoder/AnyElementEncoder.php b/src/Encoder/AnyElementEncoder.php new file mode 100644 index 0000000..0b01507 --- /dev/null +++ b/src/Encoder/AnyElementEncoder.php @@ -0,0 +1,92 @@ + + * + * @psalm-import-type LookupArray from DocumentToLookupArrayReader + * + * @template-implements Feature\ProvidesObjectDecoderLens + */ +final class AnyElementEncoder implements Feature\ListAware, Feature\OptionalAware, Feature\ProvidesObjectDecoderLens, XmlEncoder +{ + /** + * This lens will be used to decode XML into an 'any' property. + * It will contain all the XML tags available in the object that is surrounding the 'any' property. + * Properties that are already known by the object, will be omitted. + * + * @return Lens + */ + public static function createObjectDecoderLens(Type $parentType, Property $currentProperty): Lens + { + $omittedKeys = reduce( + $parentType->getProperties(), + static fn (array $omit, Property $property): array => [ + ...$omit, + ...($property->getName() !== $currentProperty->getName() ? [$property->getName()] : []), + ], + [] + ); + + /** + * @param LookupArray $data + * @return LookupArray + */ + $omit = static fn (array $data): array => diff_by_key($data, array_flip($omittedKeys)); + + /** @var Lens */ + return Lens::readonly( + /** + * @psalm-suppress MixedArgumentTypeCoercion - Psalm gets confused about the result of omit. + * @param LookupArray $data + */ + static fn (array $data): ElementList => ElementList::fromLookupArray($omit($data)) + ); + } + + /** + * @return Iso + */ + public function iso(Context $context): Iso + { + $meta = $context->type->getMeta(); + $isNullable = $meta->isNullable()->unwrapOr(false); + $isList = $meta->isList()->unwrapOr(false); + + return new Iso( + static fn (string|array|null $raw): string => match (true) { + is_string($raw) => $raw, + is_array($raw) => join(vec(string())->assert($raw), ''), + default => '', + }, + /** + * @psalm-suppress DocblockTypeContradiction - Psalm gets confused about the return type of first() in default case. + * @psalm-return null|array|string + */ + static fn (ElementList|string $xml): mixed => match(true) { + is_string($xml) => $xml, + $isList && !$xml->hasElements() => [], + $isNullable && !$xml->hasElements() => null, + $isList => $xml->traverse(static fn (Element $element) => $element->value()), + default => first($xml->elements())?->value(), + } + ); + } +} diff --git a/src/Encoder/ErrorHandlingEncoder.php b/src/Encoder/ErrorHandlingEncoder.php index 95cfa41..9aea9c9 100644 --- a/src/Encoder/ErrorHandlingEncoder.php +++ b/src/Encoder/ErrorHandlingEncoder.php @@ -12,9 +12,9 @@ * @template-covariant TXml * * @implements XmlEncoder - * + * @implements Feature\DecoratingEncoder */ -final class ErrorHandlingEncoder implements XmlEncoder +final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, XmlEncoder { /** * @param XmlEncoder $encoder @@ -24,6 +24,14 @@ public function __construct( ) { } + /** + * @return XmlEncoder + */ + public function decoratedEncoder(): XmlEncoder + { + return $this->encoder; + } + /** * @return Iso */ diff --git a/src/Encoder/Feature/DecoratingEncoder.php b/src/Encoder/Feature/DecoratingEncoder.php new file mode 100644 index 0000000..97ae597 --- /dev/null +++ b/src/Encoder/Feature/DecoratingEncoder.php @@ -0,0 +1,17 @@ + + */ + public function decoratedEncoder(): XmlEncoder; +} diff --git a/src/Encoder/Feature/ProvidesObjectDecoderLens.php b/src/Encoder/Feature/ProvidesObjectDecoderLens.php new file mode 100644 index 0000000..4e9e1af --- /dev/null +++ b/src/Encoder/Feature/ProvidesObjectDecoderLens.php @@ -0,0 +1,21 @@ + + */ + public static function createObjectDecoderLens(Type $parentType, Property $currentProperty): Lens; +} diff --git a/src/Encoder/Feature/ProvidesObjectEncoderLens.php b/src/Encoder/Feature/ProvidesObjectEncoderLens.php new file mode 100644 index 0000000..82c7551 --- /dev/null +++ b/src/Encoder/Feature/ProvidesObjectEncoderLens.php @@ -0,0 +1,21 @@ + + */ + public static function createObjectEncoderLens(Type $parentType, Property $currentProperty): Lens; +} diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 36595fb..db1a69f 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -6,6 +6,7 @@ use Soap\Encoding\Normalizer\PhpPropertyNameNormalizer; use Soap\Encoding\TypeInference\ComplexTypeBuilder; use Soap\Engine\Metadata\Model\Property; +use Soap\Engine\Metadata\Model\Type; use Soap\Engine\Metadata\Model\TypeMeta; use VeeWee\Reflecta\Iso\Iso; use VeeWee\Reflecta\Lens\Lens; @@ -47,17 +48,21 @@ public static function forContext(Context $context): self $isAnyPropertyQualified = false; foreach ($sortedProperties as $property) { - $typeMeta = $property->getType()->getMeta(); + $propertyType = $property->getType(); + $propertyTypeMeta = $propertyType->getMeta(); + $propertyContext = $context->withType($propertyType); $name = $property->getName(); $normalizedName = PhpPropertyNameNormalizer::normalize($name); - $shouldLensBeOptional = self::shouldLensBeOptional($typeMeta); + $encoder = $context->registry->detectEncoderForContext($propertyContext); + $shouldLensBeOptional = self::shouldLensBeOptional($propertyTypeMeta); $normalizedProperties[$normalizedName] = $property; - $encoderLenses[$normalizedName] = $shouldLensBeOptional ? optional(property($normalizedName)) : property($normalizedName); - $decoderLenses[$normalizedName] = $shouldLensBeOptional ? optional(index($name)) : index($name); - $isos[$normalizedName] = self::grabIsoForProperty($context, $property); - $isAnyPropertyQualified = $isAnyPropertyQualified || $typeMeta->isQualified()->unwrapOr(false); + $encoderLenses[$normalizedName] = self::createEncoderLensForType($shouldLensBeOptional, $normalizedName, $encoder, $type, $property); + $decoderLenses[$normalizedName] = self::createDecoderLensForType($shouldLensBeOptional, $name, $encoder, $type, $property); + $isos[$normalizedName] = $encoder->iso($propertyContext); + + $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); } return new self( @@ -69,6 +74,46 @@ public static function forContext(Context $context): self ); } + /** + * @return Lens + */ + private static function createEncoderLensForType( + bool $shouldLensBeOptional, + string $normalizedName, + XmlEncoder $encoder, + Type $type, + Property $property, + ): Lens { + $lens = match (true) { + $encoder instanceof Feature\DecoratingEncoder => self::createEncoderLensForType($shouldLensBeOptional, $normalizedName, $encoder->decoratedEncoder(), $type, $property), + $encoder instanceof Feature\ProvidesObjectEncoderLens => $encoder::createObjectEncoderLens($type, $property), + default => property($normalizedName) + }; + + /** @var Lens */ + return $shouldLensBeOptional ? optional($lens) : $lens; + } + + /** + * @return Lens + */ + private static function createDecoderLensForType( + bool $shouldLensBeOptional, + string $name, + XmlEncoder $encoder, + Type $type, + Property $property, + ): Lens { + $lens = match(true) { + $encoder instanceof Feature\DecoratingEncoder => self::createDecoderLensForType($shouldLensBeOptional, $name, $encoder->decoratedEncoder(), $type, $property), + $encoder instanceof Feature\ProvidesObjectDecoderLens => $encoder::createObjectDecoderLens($type, $property), + default => index($name), + }; + + /** @var Lens */ + return $shouldLensBeOptional ? optional($lens) : $lens; + } + private static function shouldLensBeOptional(TypeMeta $meta): bool { if ($meta->isNullable()->unwrapOr(false)) { @@ -84,15 +129,4 @@ private static function shouldLensBeOptional(TypeMeta $meta): bool return false; } - - /** - * @return Iso - */ - private static function grabIsoForProperty(Context $context, Property $property): Iso - { - $propertyContext = $context->withType($property->getType()); - - return $context->registry->detectEncoderForContext($propertyContext) - ->iso($propertyContext); - } } diff --git a/src/EncoderRegistry.php b/src/EncoderRegistry.php index 1a00003..695e7fa 100644 --- a/src/EncoderRegistry.php +++ b/src/EncoderRegistry.php @@ -5,6 +5,7 @@ use Psl\Collection\MutableMap; use Soap\Encoding\ClassMap\ClassMapCollection; +use Soap\Encoding\Encoder\AnyElementEncoder; use Soap\Encoding\Encoder\Context; use Soap\Encoding\Encoder\ElementEncoder; use Soap\Encoding\Encoder\EncoderDetector; @@ -106,7 +107,6 @@ public static function default(): self $qNameFormatter($xsd, 'decimal') => new SimpleType\FloatTypeEncoder(), // Scalar: - $qNameFormatter($xsd, 'any') => new SimpleType\ScalarTypeEncoder(), $qNameFormatter($xsd, 'anyType') => new SimpleType\ScalarTypeEncoder(), $qNameFormatter($xsd, 'anyXML') => new SimpleType\ScalarTypeEncoder(), $qNameFormatter($xsd, 'anySimpleType') => new SimpleType\ScalarTypeEncoder(), @@ -159,6 +159,9 @@ public static function default(): self // Apache Map $qNameFormatter(ApacheMapDetector::NAMESPACE, 'Map') => new SoapEnc\ApacheMapEncoder(), + + // Special XSD cases + $qNameFormatter($xsd, 'any') => new AnyElementEncoder(), ]) ); } diff --git a/src/Xml/Node/ElementList.php b/src/Xml/Node/ElementList.php index 044361b..806afba 100644 --- a/src/Xml/Node/ElementList.php +++ b/src/Xml/Node/ElementList.php @@ -3,11 +3,19 @@ namespace Soap\Encoding\Xml\Node; +use Closure; use DOMElement; +use Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader; +use Stringable; use VeeWee\Xml\Dom\Document; +use function Psl\Iter\reduce; +use function Psl\Vec\map; use function VeeWee\Xml\Dom\Locator\Element\children as readChildren; -final class ElementList +/** + * @psalm-import-type LookupArray from DocumentToLookupArrayReader + */ +final class ElementList implements Stringable { /** @var list */ private array $elements; @@ -20,6 +28,36 @@ public function __construct(Element ...$elements) $this->elements = $elements; } + /** + * Can be used to parse a nested array structure to a full flattened ElementList. + * + * @see \Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader::__invoke + * + * @param LookupArray $data + */ + public static function fromLookupArray(array $data): self + { + return new self( + ...reduce( + $data, + /** + * @param list $elements + * + * @return list + */ + static fn (array $elements, string|Element|ElementList $value) => [ + ...$elements, + ...match(true) { + $value instanceof Element => [$value], + $value instanceof ElementList => $value->elements(), + default => [], // Strings are considered simpleTypes - not elements + } + ], + [], + ) + ); + } + /** * @param non-empty-string $xml */ @@ -48,4 +86,29 @@ public function elements(): array { return $this->elements; } + + public function hasElements(): bool + { + return (bool) $this->elements; + } + + /** + * @template R + * @param Closure(Element): R $mapper + * @return list + */ + public function traverse(Closure $mapper): array + { + return map($this->elements, $mapper); + } + + public function value(): string + { + return implode('', $this->traverse(static fn (Element $element): string => $element->value())); + } + + public function __toString() + { + return $this->value(); + } } diff --git a/src/Xml/Reader/DocumentToLookupArrayReader.php b/src/Xml/Reader/DocumentToLookupArrayReader.php index c60d482..2bbcaa2 100644 --- a/src/Xml/Reader/DocumentToLookupArrayReader.php +++ b/src/Xml/Reader/DocumentToLookupArrayReader.php @@ -9,10 +9,13 @@ use Soap\Encoding\Xml\Node\ElementList; use function VeeWee\Xml\Dom\Predicate\is_element; +/** + * @psalm-type LookupArray = array + */ final class DocumentToLookupArrayReader { /** - * @return array + * @return LookupArray */ public function __invoke(Element $xml): array { @@ -55,7 +58,7 @@ public function __invoke(Element $xml): array /** @var \iterable $attributes */ $attributes = $root->attributes; foreach ($attributes as $attribute) { - $key = $attribute->localName ?? 'unkown'; + $key = $attribute->localName ?? 'unknown'; $nodes[$key] = $attribute->value; } diff --git a/tests/PhpCompatibility/Implied/ImpliedSchema005Test.php b/tests/PhpCompatibility/Implied/ImpliedSchema005Test.php new file mode 100644 index 0000000..c3fbb4e --- /dev/null +++ b/tests/PhpCompatibility/Implied/ImpliedSchema005Test.php @@ -0,0 +1,62 @@ + + + + + + + + + + EOXML; + protected string $type = 'type="tns:testType"'; + + protected function calculateParam(): mixed + { + return (object)[ + 'customerName' => 'John Doe', + 'customerEmail' => 'john@doe.com', + 'any' => [ + 'world', + 'moon', + ], + ]; + } + + protected function expectXml(): string + { + return << + + + + John Doe + john@doe.com + world + moon + + + + + XML; + } +} diff --git a/tests/PhpCompatibility/Implied/ImpliedSchema006Test.php b/tests/PhpCompatibility/Implied/ImpliedSchema006Test.php new file mode 100644 index 0000000..050451c --- /dev/null +++ b/tests/PhpCompatibility/Implied/ImpliedSchema006Test.php @@ -0,0 +1,60 @@ + + + + + + + + EOXML; + protected string $type = 'type="tns:testType"'; + + protected function calculateParam(): mixed + { + return (object)[ + 'any' => [ + 'John Doe', + 'john@doe.com', + 'world', + 'moon', + ], + ]; + } + + protected function expectXml(): string + { + return << + + + + John Doe + john@doe.com + world + moon + + + + + XML; + } +} diff --git a/tests/PhpCompatibility/Implied/ImpliedSchema007Test.php b/tests/PhpCompatibility/Implied/ImpliedSchema007Test.php new file mode 100644 index 0000000..acb8e88 --- /dev/null +++ b/tests/PhpCompatibility/Implied/ImpliedSchema007Test.php @@ -0,0 +1,50 @@ + + + + + + + + EOXML; + protected string $type = 'type="tns:testType"'; + + protected function calculateParam(): mixed + { + return (object)[ + 'any' => null + ]; + } + + protected function expectXml(): string + { + return << + + + + + + + XML; + } +} diff --git a/tests/PhpCompatibility/Implied/ImpliedSchema008Test.php b/tests/PhpCompatibility/Implied/ImpliedSchema008Test.php new file mode 100644 index 0000000..da66077 --- /dev/null +++ b/tests/PhpCompatibility/Implied/ImpliedSchema008Test.php @@ -0,0 +1,52 @@ + + + + + + + + EOXML; + protected string $type = 'type="tns:testType"'; + + protected function calculateParam(): mixed + { + return (object)[ + 'any' => '' + ]; + } + + protected function expectXml(): string + { + return << + + + + + + + + + XML; + } +} diff --git a/tests/Unit/Xml/Node/ElementListTest.php b/tests/Unit/Xml/Node/ElementListTest.php index c072cee..277984b 100644 --- a/tests/Unit/Xml/Node/ElementListTest.php +++ b/tests/Unit/Xml/Node/ElementListTest.php @@ -39,4 +39,20 @@ public function test_it_can_be_constructed(): void static::assertCount(1, $list->elements()); static::assertSame($xml, $list->elements()[0]->value()); } + + public function test_it_can_load_nested_list(): void + { + $list = ElementList::fromLookupArray([ + 'hello' => $hello = Element::fromString('world'), + 'world' => '', + '_' => '', + 'attr' => 'foo', + 'list' => new ElementList( + $list1 = Element::fromString('1'), + $list2 = Element::fromString('2'), + ) + ]); + + static::assertSame([$hello, $list1, $list2], $list->elements()); + } }