diff --git a/src/Encoder/XsiTypeEncoder.php b/src/Encoder/XsiTypeEncoder.php index 1f92fb1..fb2b520 100644 --- a/src/Encoder/XsiTypeEncoder.php +++ b/src/Encoder/XsiTypeEncoder.php @@ -2,6 +2,7 @@ namespace Soap\Encoding\Encoder; +use Closure; use Soap\Encoding\TypeInference\XsiTypeDetector; use Soap\Encoding\Xml\Node\Element; use Soap\Encoding\Xml\Node\ElementList; @@ -28,15 +29,19 @@ public function __construct( public function iso(Context $context): Iso { $innerIso = $this->encoder->iso($context); + $detectEncoder = $this->encoder instanceof Feature\DisregardXsiInformation + ? null + : XsiTypeDetector::encoderDetectorForContext($context); return new Iso( function (mixed $value) use ($innerIso) : string { return $this->to($innerIso, $value); }, - function (string|Element $value) use ($context, $innerIso) : mixed { + function (string|Element $value) use ($context, $innerIso, $detectEncoder) : mixed { return $this->from( $context, $innerIso, + $detectEncoder, ($value instanceof Element ? $value : Element::fromString(non_empty_string()->assert($value))) ); } @@ -60,15 +65,11 @@ private function to(IsoInterface $innerIso, mixed $value): string /** * @param IsoInterface $innerIso + * @param (Closure(\Dom\Element): (FixedIsoEncoder|null))|null $detectEncoder */ - private function from(Context $context, IsoInterface $innerIso, Element $value): mixed + private function from(Context $context, IsoInterface $innerIso, ?Closure $detectEncoder, Element $value): mixed { - $iso = match (true) { - $this->encoder instanceof Feature\DisregardXsiInformation => $innerIso, - default => XsiTypeDetector::detectEncoderFromXmlElement($context, $value->element()) - ->map(static fn (XmlEncoder $encoder): IsoInterface => $encoder->iso($context)) - ->unwrapOr($innerIso), - }; + $iso = ($detectEncoder ? $detectEncoder($value->element()) : null)?->iso($context) ?? $innerIso; /** @psalm-suppress ImplicitToStringCast - Encoders accept string|Element in from() */ return $iso->from($value); diff --git a/src/TypeInference/XsiTypeDetector.php b/src/TypeInference/XsiTypeDetector.php index 2028ffb..d3e7b57 100644 --- a/src/TypeInference/XsiTypeDetector.php +++ b/src/TypeInference/XsiTypeDetector.php @@ -3,6 +3,7 @@ namespace Soap\Encoding\TypeInference; +use Closure; use Dom\Element; use Psl\Option\Option; use Soap\Encoding\Cache\ScopedCache; @@ -20,6 +21,7 @@ use function is_float; use function is_int; use function is_string; +use function Psl\Option\from_nullable; use function Psl\Option\none; use function Psl\Option\some; use function sprintf; @@ -63,32 +65,12 @@ static function () use ($context, $value) { */ public static function detectXsdTypeFromXmlElement(Context $context, Element $element): Option { - $xsiType = $element->getAttributeNS(Xmlns::xsi()->value(), 'type') ?? $element->getAttribute('xsi:type'); - if ($xsiType === null || $xsiType === '') { - return none(); - } - - [$prefix, $localName] = (new QnameParser)($xsiType); - if (!$localName) { - return none(); - } - - $namespaceUri = $prefix - ? $element->lookupNamespaceURI($prefix) ?? $context->namespaces->lookupNamespaceFromName($prefix)->unwrapOr(null) - : $element->lookupNamespaceURI(null) ?? $element->namespaceURI; - - if ($namespaceUri === null || $namespaceUri === '') { + $qname = self::detectQNameFromXmlElement($context, $element); + if ($qname === null) { return none(); } - return some( - // We create a new type based on the detected xsi:type, but we keep the meta information of the original type. - // This way we can still detect if the type is nullable, a union, used on an element, ... - $context->type - ->copy($localName) - ->withXmlTypeName($localName) - ->withXmlNamespace($namespaceUri) - ); + return some(self::createXsdType($context, $qname[0], $qname[1])); } /** @@ -96,19 +78,34 @@ public static function detectXsdTypeFromXmlElement(Context $context, Element $el */ public static function detectEncoderFromXmlElement(Context $context, Element $element): Option { - $requestedXsiType = self::detectXsdTypeFromXmlElement($context, $element); - if (!$requestedXsiType->isSome()) { - return none(); - } + return from_nullable(self::encoderDetectorForContext($context)($element)); + } + + /** + * The slot part of the cache key only depends on the context, + * so an iso can resolve it once and reuse the detector for every element it decodes. + * + * @internal + * + * @return Closure(Element): (FixedIsoEncoder|null) + */ + public static function encoderDetectorForContext(Context $context): Closure + { + $slotKey = self::slotKey($context->type->getMeta()); - $requestedType = $requestedXsiType->unwrap(); + return static function (Element $element) use ($context, $slotKey): ?FixedIsoEncoder { + $qname = self::detectQNameFromXmlElement($context, $element); + if ($qname === null) { + return null; + } - return some( - self::cache()->lookup( + [$namespaceUri, $localName] = $qname; + + return self::cache()->lookup( $context->registry, - self::cacheKey($requestedType), - static function () use ($context, $requestedType): FixedIsoEncoder { - $type = self::withResolvedSimpleness($context, $requestedType); + $namespaceUri . '|' . $localName . '|' . $slotKey, + static function () use ($context, $namespaceUri, $localName): FixedIsoEncoder { + $type = self::withResolvedSimpleness($context, self::createXsdType($context, $namespaceUri, $localName)); $normalizedMeta = $type->getMeta() ->withIsNullable(false) ->withIsRepeatingElement(false); @@ -122,16 +119,51 @@ static function () use ($context, $requestedType): FixedIsoEncoder { ) ); } - ) - ); + ); + }; + } + + /** + * @return array{non-empty-string, non-empty-string}|null + */ + private static function detectQNameFromXmlElement(Context $context, Element $element): ?array + { + $xsiType = $element->getAttributeNS(Xmlns::xsi()->value(), 'type') ?? $element->getAttribute('xsi:type'); + if ($xsiType === null || $xsiType === '') { + return null; + } + + [$prefix, $localName] = (new QnameParser)($xsiType); + if (!$localName) { + return null; + } + + $namespaceUri = $prefix + ? $element->lookupNamespaceURI($prefix) ?? $context->namespaces->lookupNamespaceFromName($prefix)->unwrapOr(null) + : $element->lookupNamespaceURI(null) ?? $element->namespaceURI; + + if ($namespaceUri === null || $namespaceUri === '') { + return null; + } + + return [$namespaceUri, $localName]; } - private static function cacheKey(XsdType $type): string + /** + * We create a new type based on the detected xsi:type, but we keep the meta information of the original type. + * This way we can still detect if the type is nullable, a union, used on an element, ... + */ + private static function createXsdType(Context $context, string $namespaceUri, string $localName): XsdType { - $meta = $type->getMeta(); + return $context->type + ->copy($localName) + ->withXmlTypeName($localName) + ->withXmlNamespace($namespaceUri); + } - return $type->getXmlNamespace() . '|' . $type->getXmlTypeName() - . '|' . ($meta->isElement()->unwrapOr(false) ? 'e' : '') + private static function slotKey(TypeMeta $meta): string + { + return ($meta->isElement()->unwrapOr(false) ? 'e' : '') . ($meta->isAttribute()->unwrapOr(false) ? 'a' : '') . ($meta->isNullable()->unwrapOr(false) ? 'n' : '') . ($meta->isList()->unwrapOr(false) ? 'l' : '') diff --git a/tests/Unit/Encoder/XsiTypeEncoderTest.php b/tests/Unit/Encoder/XsiTypeEncoderTest.php new file mode 100644 index 0000000..0483dc9 --- /dev/null +++ b/tests/Unit/Encoder/XsiTypeEncoderTest.php @@ -0,0 +1,88 @@ +withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsElement(true)) + ); + $iso = $context->registry->detectEncoderForContext($context)->iso($context); + + static::assertSame(1, $iso->from($this->element('1'))); + static::assertSame('1', $iso->from($this->element('1'))); + static::assertTrue($iso->from($this->element('true'))); + } + + public function test_one_iso_falls_back_to_the_declared_type_for_elements_without_xsi_type(): void + { + $schema = << + + + + + EOXML; + $metadata = self::createMetadataFromWsdl($schema, 'type="tns:itemType"'); + $context = self::createContextFromMetadata($metadata, 'itemType'); + $iso = $context->registry->detectEncoderForContext($context)->iso($context); + + static::assertEquals((object) ['id' => 1], $iso->from($this->element('1'))); + static::assertSame('abc', $iso->from($this->element('abc'))); + static::assertEquals((object) ['id' => 2], $iso->from($this->element('2'))); + } + + public function test_it_ignores_xsi_type_for_encoders_that_disregard_it(): void + { + $context = self::createContext( + XsdType::any()->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsElement(true)) + ); + $iso = (new XsiTypeEncoder(new DisregardingEncoder()))->iso($context); + + static::assertSame('inner', $iso->from($this->element('1'))); + } + + private function element(string $xml): Element + { + return Element::fromString($xml); + } +} + +/** + * @implements XmlEncoder + */ +final class DisregardingEncoder implements DisregardXsiInformation, XmlEncoder +{ + /** + * @return Iso + */ + public function iso(Context $context): Iso + { + return new Iso( + static fn (mixed $value): string => '', + static fn (Element|string $value): string => 'inner', + ); + } +}