Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions src/Encoder/XsiTypeEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)))
);
}
Expand All @@ -60,15 +65,11 @@ private function to(IsoInterface $innerIso, mixed $value): string

/**
* @param IsoInterface<mixed, mixed, string|null, Element|ElementList|string|null> $innerIso
* @param (Closure(\Dom\Element): (FixedIsoEncoder<mixed, mixed, string|null, Element|ElementList|string|null>|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);
Expand Down
110 changes: 71 additions & 39 deletions src/TypeInference/XsiTypeDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace Soap\Encoding\TypeInference;

use Closure;
use Dom\Element;
use Psl\Option\Option;
use Soap\Encoding\Cache\ScopedCache;
Expand All @@ -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;
Expand Down Expand Up @@ -63,52 +65,47 @@ 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]));
}

/**
* @return Option<FixedIsoEncoder<mixed, mixed, string|null, XmlElement|ElementList|string|null>>
*/
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<mixed, mixed, string|null, XmlElement|ElementList|string|null>|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);
Expand All @@ -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' : '')
Expand Down
88 changes: 88 additions & 0 deletions tests/Unit/Encoder/XsiTypeEncoderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);

namespace Soap\Encoding\Test\Unit\Encoder;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Soap\Encoding\Encoder\Context;
use Soap\Encoding\Encoder\Feature\DisregardXsiInformation;
use Soap\Encoding\Encoder\XmlEncoder;
use Soap\Encoding\Encoder\XsiTypeEncoder;
use Soap\Encoding\Test\Unit\ContextCreatorTrait;
use Soap\Encoding\TypeInference\XsiTypeDetector;
use Soap\Encoding\Xml\Node\Element;
use Soap\Engine\Metadata\Model\TypeMeta;
use Soap\Engine\Metadata\Model\XsdType;
use VeeWee\Reflecta\Iso\Iso;

#[CoversClass(XsiTypeEncoder::class)]
#[CoversClass(XsiTypeDetector::class)]
final class XsiTypeEncoderTest extends TestCase
{
use ContextCreatorTrait;

private const NAMESPACES = 'xmlns:tns="https://test" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"';

public function test_one_iso_decodes_every_element_by_its_own_xsi_type(): void
{
$context = self::createContext(
XsdType::any()->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsElement(true))
);
$iso = $context->registry->detectEncoderForContext($context)->iso($context);

static::assertSame(1, $iso->from($this->element('<item ' . self::NAMESPACES . ' xsi:type="xsd:int">1</item>')));
static::assertSame('1', $iso->from($this->element('<item ' . self::NAMESPACES . ' xsi:type="xsd:string">1</item>')));
static::assertTrue($iso->from($this->element('<item ' . self::NAMESPACES . ' xsi:type="xsd:boolean">true</item>')));
}

public function test_one_iso_falls_back_to_the_declared_type_for_elements_without_xsi_type(): void
{
$schema = <<<EOXML
<complexType name="itemType">
<sequence>
<element name="id" type="xsd:int"/>
</sequence>
</complexType>
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('<item ' . self::NAMESPACES . '><id>1</id></item>')));
static::assertSame('abc', $iso->from($this->element('<item ' . self::NAMESPACES . ' xsi:type="xsd:string">abc</item>')));
static::assertEquals((object) ['id' => 2], $iso->from($this->element('<item ' . self::NAMESPACES . '><id>2</id></item>')));
}

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('<item ' . self::NAMESPACES . ' xsi:type="xsd:int">1</item>')));
}

private function element(string $xml): Element
{
return Element::fromString($xml);
}
}

/**
* @implements XmlEncoder<mixed, mixed, string, Element|string>
*/
final class DisregardingEncoder implements DisregardXsiInformation, XmlEncoder
{
/**
* @return Iso<mixed, mixed, string, Element|string>
*/
public function iso(Context $context): Iso
{
return new Iso(
static fn (mixed $value): string => '<item/>',
static fn (Element|string $value): string => 'inner',
);
}
}
Loading