diff --git a/.changes/nextrelease/serde-plan-cache.json b/.changes/nextrelease/serde-plan-cache.json new file mode 100644 index 0000000000..77423e83ba --- /dev/null +++ b/.changes/nextrelease/serde-plan-cache.json @@ -0,0 +1,7 @@ +[ + { + "type": "enhancement", + "category": "Api", + "description": "Caches compiled JSON and XML serialization and parsing plans on model shapes, reducing per-request serialization and parsing overhead. Wire output and parsed results are unchanged, and plans are invalidated when a shape is mutated." + } +] diff --git a/src/Api/AbstractModel.php b/src/Api/AbstractModel.php index 2c9b412d09..381fe25e1c 100644 --- a/src/Api/AbstractModel.php +++ b/src/Api/AbstractModel.php @@ -15,6 +15,12 @@ abstract class AbstractModel implements \ArrayAccess /** @var array */ protected $contextParam; + /** @var array Cached serde plans keyed by ShapePlanCache slot. */ + protected $cachedPlans = []; + + /** @var int Graph generation the cached plans were built against. */ + protected $planGeneration = 0; + /** * @param array $definition Service description * @param ShapeMap $shapeMap Shapemap used for creating shapes @@ -28,6 +34,43 @@ public function __construct(array $definition, ShapeMap $shapeMap) } } + /** + * Get a cached serde plan for the given slot. + * + * Returns null when no plan is cached, or when the cache is stale because + * a related model object mutated since the plan was built. + * + * @param int $slot A ShapePlanCache slot constant. + * + * @return mixed|null + * @internal + */ + public function getSerdePlan($slot) + { + $this->syncPlanGeneration(); + + return $this->cachedPlans[$slot] ?? null; + } + + /** + * Cache a serde plan for the given slot and return it. + * + * Returns the plan so providers can compile and cache in one expression. + * + * @param int $slot A ShapePlanCache slot constant. + * @param mixed $plan The compiled plan to cache. + * + * @return mixed The cached plan. + * @internal + */ + public function cacheSerdePlan($slot, $plan) + { + $this->syncPlanGeneration(); + $this->cachedPlans[$slot] = $plan; + + return $plan; + } + public function toArray() { return $this->definition; @@ -50,6 +93,7 @@ public function offsetGet($offset) public function offsetSet($offset, $value) { $this->definition[$offset] = $value; + $this->invalidateResolvedModel(); } /** @@ -68,6 +112,60 @@ public function offsetExists($offset) public function offsetUnset($offset) { unset($this->definition[$offset]); + $this->invalidateResolvedModel(); + } + + /** + * Clear resolved child model objects cached on this instance. + * + * The base model caches no resolved children. Subclasses that memoize + * resolved members, elements, or shapes override this to drop them so a + * definition change is reflected on the next access. + * + * @return void + */ + protected function clearResolvedModelCache() + { + // No resolved children on the base model. + } + + /** + * Drop resolved children and cached plans after a definition mutation, then + * advance the graph generation so plans derived on related objects rebuild. + * + * Safe when no ShapeMap is present, which happens for mocks that construct + * model objects directly. + * + * @return void + */ + private function invalidateResolvedModel() + { + $this->clearResolvedModelCache(); + $this->cachedPlans = []; + + if ($this->shapeMap !== null) { + $this->shapeMap->incrementGeneration(); + $this->planGeneration = $this->shapeMap->getGeneration(); + } + } + + /** + * Drop cached plans when the graph generation has moved past the one they + * were built against. + * + * @return void + */ + private function syncPlanGeneration() + { + if ($this->shapeMap === null) { + return; + } + + $generation = $this->shapeMap->getGeneration(); + if ($this->planGeneration !== $generation) { + $this->cachedPlans = []; + $this->planGeneration = $generation; + } } protected function shapeAt($key) diff --git a/src/Api/ListShape.php b/src/Api/ListShape.php index a425efa72f..080e7df1df 100644 --- a/src/Api/ListShape.php +++ b/src/Api/ListShape.php @@ -32,4 +32,12 @@ public function getMember() return $this->member; } + + /** + * @return void + */ + protected function clearResolvedModelCache() + { + $this->member = null; + } } diff --git a/src/Api/MapShape.php b/src/Api/MapShape.php index f180f9a68a..baafacaba7 100644 --- a/src/Api/MapShape.php +++ b/src/Api/MapShape.php @@ -51,4 +51,13 @@ public function getKey() return $this->key; } + + /** + * @return void + */ + protected function clearResolvedModelCache() + { + $this->value = null; + $this->key = null; + } } diff --git a/src/Api/Operation.php b/src/Api/Operation.php index 43c695e72b..89b3ba4c08 100644 --- a/src/Api/Operation.php +++ b/src/Api/Operation.php @@ -11,6 +11,7 @@ class Operation extends AbstractModel private $errors; private $staticContextParams = []; private $contextParams; + private $contextParamsGeneration; private $operationContextParams = []; public function __construct(array $definition, ShapeMap $shapeMap) @@ -35,6 +36,7 @@ public function __construct(array $definition, ShapeMap $shapeMap) parent::__construct($definition, $shapeMap); $this->contextParams = $this->setContextParams(); + $this->contextParamsGeneration = $shapeMap->getGeneration(); } /** @@ -126,6 +128,14 @@ public function getStaticContextParams() */ public function getContextParams() { + // Context params derive from the input shape graph, so rebuild them + // when any shape in the shared ShapeMap has been mutated. + $generation = $this->shapeMap->getGeneration(); + if ($this->contextParams === null || $this->contextParamsGeneration !== $generation) { + $this->contextParams = $this->setContextParams(); + $this->contextParamsGeneration = $generation; + } + return $this->contextParams; } @@ -140,12 +150,28 @@ public function getOperationContextParams(): array return $this->operationContextParams; } + /** + * @return void + */ + protected function clearResolvedModelCache() + { + $this->input = null; + $this->output = null; + $this->errors = null; + + // Context params derive from the definition and the input shape, so + // refresh them too. Dynamic context params rebuild on next access. + $this->staticContextParams = $this->definition['staticContextParams'] ?? []; + $this->operationContextParams = $this->definition['operationContextParams'] ?? []; + $this->contextParams = null; + } + private function setContextParams() { $members = $this->getInput()->getMembers(); $contextParams = []; - foreach($members as $name => $shape) { + foreach ($members as $name => $shape) { if (!empty($contextParam = $shape->getContextParam())) { $contextParams[$contextParam['name']] = [ 'shape' => $name, diff --git a/src/Api/Parser/JsonParser.php b/src/Api/Parser/JsonParser.php index da11e31bd2..571f126717 100644 --- a/src/Api/Parser/JsonParser.php +++ b/src/Api/Parser/JsonParser.php @@ -2,6 +2,9 @@ namespace Aws\Api\Parser; use Aws\Api\DateTimeResult; +use Aws\Api\Serde\Json\JsonDecodePlan; +use Aws\Api\Serde\Json\JsonDecodePlanProvider; +use Aws\Api\Serde\Json\JsonShapeType; use Aws\Api\Shape; /** @@ -9,66 +12,111 @@ */ class JsonParser { + /** @var JsonDecodePlanProvider */ + private $planProvider; + + public function __construct() + { + $this->planProvider = new JsonDecodePlanProvider(); + } + public function parse(Shape $shape, $value) { if ($value === null) { return $value; } - switch ($shape['type']) { - case 'structure': - if (isset($shape['document']) && $shape['document']) { - return $value; - } + return $this->parsePlan($this->planProvider->get($shape), $value); + } + + /** + * Decodes a value using a compiled plan instead of re-reading the model. + * + * Preserves modeled member order and union handling. + */ + private function parsePlan(JsonDecodePlan $plan, $value) + { + switch ($plan->type) { + case JsonShapeType::STRUCTURE: $target = []; - foreach ($shape->getMembers() as $name => $member) { - $locationName = $member['locationName'] ?: $name; - if (isset($value[$locationName])) { - $target[$name] = $this->parse($member, $value[$locationName]); + foreach ($plan->members as $member) { + $wire = $member[JsonDecodePlan::M_WIRE]; + if (isset($value[$wire])) { + $target[$member[JsonDecodePlan::M_SDK]] = $this->parseByType( + $member[JsonDecodePlan::M_TYPE], + $member[JsonDecodePlan::M_SHAPE], + $member[JsonDecodePlan::M_TSFORMAT], + $value[$wire] + ); } } - if (isset($shape['union']) - && $shape['union'] - && is_array($value) - && empty($target) - ) { + if ($plan->union && is_array($value) && empty($target)) { foreach ($value as $key => $val) { $target['Unknown'][$key] = $val; } } return $target; - case 'list': - $member = $shape->getMember(); + case JsonShapeType::LIST: + $type = $plan->value[JsonDecodePlan::V_TYPE]; + $shape = $plan->value[JsonDecodePlan::V_SHAPE]; + $ts = $plan->value[JsonDecodePlan::V_TSFORMAT]; $target = []; foreach ($value as $v) { - $target[] = $this->parse($member, $v); + $target[] = $this->parseByType($type, $shape, $ts, $v); } return $target; - case 'map': - $values = $shape->getValue(); + case JsonShapeType::MAP: + $type = $plan->value[JsonDecodePlan::V_TYPE]; + $shape = $plan->value[JsonDecodePlan::V_SHAPE]; + $ts = $plan->value[JsonDecodePlan::V_TSFORMAT]; $target = []; foreach ($value as $k => $v) { // null map values should not be deserialized if (!is_null($v)) { - $target[$k] = $this->parse($values, $v); + $target[$k] = $this->parseByType($type, $shape, $ts, $v); } } return $target; - case 'timestamp': - return DateTimeResult::fromTimestamp( - $value, - !empty($shape['timestampFormat']) ? $shape['timestampFormat'] : null - ); + case JsonShapeType::TIMESTAMP: + return DateTimeResult::fromTimestamp($value, $plan->timestampFormat); - case 'blob': + case JsonShapeType::BLOB: return base64_decode($value); - default: + default: // SCALAR, DOCUMENT return $value; } } -} + /** + * Decodes one member or collection element. Composite children fetch their + * own plan lazily; leaf types are handled inline. + */ + private function parseByType(int $type, Shape $shape, ?string $tsFormat, $value) + { + // A null value is returned as-is for every shape type, so sparse + // list elements stay null. + if ($value === null) { + return null; + } + + switch ($type) { + case JsonShapeType::STRUCTURE: + case JsonShapeType::LIST: + case JsonShapeType::MAP: + return $this->parsePlan($this->planProvider->get($shape), $value); + + case JsonShapeType::TIMESTAMP: + return DateTimeResult::fromTimestamp($value, $tsFormat); + + case JsonShapeType::BLOB: + return base64_decode($value); + + default: // SCALAR, DOCUMENT + return $value; + } + } +} diff --git a/src/Api/Parser/XmlParser.php b/src/Api/Parser/XmlParser.php index c9d47d235a..40f9cfab4b 100644 --- a/src/Api/Parser/XmlParser.php +++ b/src/Api/Parser/XmlParser.php @@ -2,10 +2,9 @@ namespace Aws\Api\Parser; use Aws\Api\DateTimeResult; -use Aws\Api\ListShape; -use Aws\Api\MapShape; -use Aws\Api\Parser\Exception\ParserException; -use Aws\Api\Shape; +use Aws\Api\Serde\Xml\XmlDecodePlan; +use Aws\Api\Serde\Xml\XmlDecodePlanProvider; +use Aws\Api\Serde\Xml\XmlShapeType; use Aws\Api\StructureShape; /** @@ -13,170 +12,254 @@ */ class XmlParser { + /** @var XmlDecodePlanProvider */ + private $planProvider; + + public function __construct() + { + $this->planProvider = new XmlDecodePlanProvider(); + } + public function parse(StructureShape $shape, \SimpleXMLElement $value) { - return $this->dispatch($shape, $value); + return $this->parsePlan($this->planProvider->get($shape), $value); } - private function dispatch($shape, \SimpleXMLElement $value) + /** + * Decodes a value using a compiled plan instead of re-reading the model. + * + * Preserves modeled member order, attribute fallback, flattening, union + * handling, and value coercion. + */ + private function parsePlan(XmlDecodePlan $plan, \SimpleXMLElement $value) { - static $methods = [ - 'structure' => 'parse_structure', - 'list' => 'parse_list', - 'map' => 'parse_map', - 'blob' => 'parse_blob', - 'boolean' => 'parse_boolean', - 'integer' => 'parse_integer', - 'float' => 'parse_float', - 'double' => 'parse_float', - 'timestamp' => 'parse_timestamp', - ]; - - $type = $shape['type']; - if (isset($methods[$type])) { - return $this->{$methods[$type]}($shape, $value); - } + switch ($plan->type) { + case XmlShapeType::STRUCTURE: + $target = []; + foreach ($plan->members as $index => $member) { + $node = $member[XmlDecodePlan::M_NODE]; + if (isset($value->{$node})) { + $type = $member[XmlDecodePlan::M_TYPE]; + $nodeValue = $value->{$node}; + switch ($type) { + case XmlShapeType::STRUCTURE: + case XmlShapeType::LIST: + case XmlShapeType::MAP: + $childPlan = $member[XmlDecodePlan::M_PLAN]; + if ($childPlan === null) { + $childPlan = $this->planProvider->get( + $member[XmlDecodePlan::M_SHAPE] + ); + $plan->members[$index][XmlDecodePlan::M_PLAN] = $childPlan; + } + $parsed = $this->parsePlan($childPlan, $nodeValue); + break; - return (string) $value; - } + case XmlShapeType::BLOB: + $parsed = base64_decode((string) $nodeValue); + break; - private function parse_structure( - StructureShape $shape, - \SimpleXMLElement $value - ) { - $target = []; - - foreach ($shape->getMembers() as $name => $member) { - // Extract the name of the XML node - $node = $this->memberKey($member, $name); - if (isset($value->{$node})) { - $target[$name] = $this->dispatch($member, $value->{$node}); - } else { - $memberShape = $shape->getMember($name); - if (!empty($memberShape['xmlAttribute'])) { - $target[$name] = $this->parse_xml_attribute( - $shape, - $memberShape, - $value - ); - } - } - } - if (isset($shape['union']) - && $shape['union'] - && empty($target) - ) { - foreach ($value as $key => $val) { - $name = $val->children()->getName(); - $target['Unknown'][$name] = $val->$name; - } - } - return $target; - } + case XmlShapeType::BOOLEAN: + $parsed = $nodeValue == 'true'; + break; - private function memberKey(Shape $shape, $name) - { - // Check if locationName came from shape definition - if ($shape instanceof StructureShape && isset($shape['locationName'])) { - $originalDef = $shape->getOriginalDefinition($shape->getName()); - - if ($originalDef && isset($originalDef['locationName']) - && $originalDef['locationName'] === $shape['locationName'] - ) { - return $name; - } - } + case XmlShapeType::TIMESTAMP: + $parsed = DateTimeResult::fromTimestamp( + (string) $nodeValue, + $member[XmlDecodePlan::M_TSFORMAT] + ); + break; - return $shape['locationName'] ?? $name; - } + default: // SCALAR + $coerce = $member[XmlDecodePlan::M_COERCE]; + if ($coerce === XmlDecodePlan::COERCE_INT) { + $parsed = (int) (string) $nodeValue; + } elseif ($coerce === XmlDecodePlan::COERCE_FLOAT) { + $s = (string) $nodeValue; + $parsed = match ($s) { + 'NaN', 'Infinity', '-Infinity' => $s, + default => (float) $s, + }; + } else { + $parsed = (string) $nodeValue; + } + } + $target[$member[XmlDecodePlan::M_SDK]] = $parsed; + } elseif ($member[XmlDecodePlan::M_ATTRIBUTE]) { + $target[$member[XmlDecodePlan::M_SDK]] = $this->readAttribute( + $member[XmlDecodePlan::M_ATTRKEY], + $member[XmlDecodePlan::M_ATTRNS], + $value + ); + } + } + if ($plan->union && empty($target)) { + foreach ($value as $key => $val) { + $name = $val->children()->getName(); + $target['Unknown'][$name] = $val->$name; + } + } + return $target; - private function parse_list(ListShape $shape, \SimpleXMLElement $value) - { - $target = []; - $member = $shape->getMember(); + case XmlShapeType::LIST: + $target = []; + if (!$plan->flattened) { + $value = $value->{$plan->listItemName}; + } + switch ($plan->listItemType) { + case XmlShapeType::STRUCTURE: + case XmlShapeType::LIST: + case XmlShapeType::MAP: + $childPlan = $plan->listItemPlan + ??= $this->planProvider->get($plan->listItemShape); + foreach ($value as $v) { + $target[] = $this->parsePlan($childPlan, $v); + } + break; - if (!$shape['flattened']) { - $value = $value->{$member['locationName'] ?: 'member'}; - } + case XmlShapeType::BLOB: + foreach ($value as $v) { + $target[] = base64_decode((string) $v); + } + break; - foreach ($value as $v) { - $target[] = $this->dispatch($member, $v); - } + case XmlShapeType::BOOLEAN: + foreach ($value as $v) { + $target[] = $v == 'true'; + } + break; - return $target; - } + case XmlShapeType::TIMESTAMP: + $timestampFormat = $plan->listItemTsFormat; + foreach ($value as $v) { + $target[] = DateTimeResult::fromTimestamp( + (string) $v, + $timestampFormat + ); + } + break; - private function parse_map(MapShape $shape, \SimpleXMLElement $value) - { - $target = []; + default: // SCALAR + if ($plan->listItemCoerce === XmlDecodePlan::COERCE_INT) { + foreach ($value as $v) { + $target[] = (int) (string) $v; + } + } elseif ($plan->listItemCoerce === XmlDecodePlan::COERCE_FLOAT) { + foreach ($value as $v) { + $s = (string) $v; + $target[] = match ($s) { + 'NaN', 'Infinity', '-Infinity' => $s, + default => (float) $s, + }; + } + } else { + foreach ($value as $v) { + $target[] = (string) $v; + } + } + } + return $target; - if (!$shape['flattened']) { - $value = $value->entry; - } + case XmlShapeType::MAP: + $target = []; + if (!$plan->flattened) { + $value = $value->entry; + } + $keyPlan = null; + if ($plan->mapKeyType >= XmlShapeType::STRUCTURE + && $plan->mapKeyType <= XmlShapeType::MAP + ) { + $keyPlan = $plan->mapKeyPlan + ??= $this->planProvider->get($plan->mapKeyShape); + } + $valuePlan = null; + if ($plan->mapValueType >= XmlShapeType::STRUCTURE + && $plan->mapValueType <= XmlShapeType::MAP + ) { + $valuePlan = $plan->mapValuePlan + ??= $this->planProvider->get($plan->mapValueShape); + } + foreach ($value as $node) { + $key = $this->parseResolvedValue( + $plan->mapKeyType, + $keyPlan, + null, + $plan->mapKeyCoerce, + $node->{$plan->mapKeyName} + ); + $target[$key] = $this->parseResolvedValue( + $plan->mapValueType, + $valuePlan, + $plan->mapValueTsFormat, + $plan->mapValueCoerce, + $node->{$plan->mapValueName} + ); + } + return $target; - $mapKey = $shape->getKey(); - $mapValue = $shape->getValue(); - $keyName = $shape->getKey()['locationName'] ?: 'key'; - $valueName = $shape->getValue()['locationName'] ?: 'value'; + case XmlShapeType::BLOB: + return base64_decode((string) $value); - foreach ($value as $node) { - $key = $this->dispatch($mapKey, $node->{$keyName}); - $value = $this->dispatch($mapValue, $node->{$valueName}); - $target[$key] = $value; - } + case XmlShapeType::BOOLEAN: + return $value == 'true'; - return $target; - } + case XmlShapeType::TIMESTAMP: + return DateTimeResult::fromTimestamp( + (string) $value, + $plan->timestampFormat + ); - private function parse_blob(Shape $shape, $value) - { - return base64_decode((string) $value); + default: // SCALAR (string, integer, float/double handled below) + return (string) $value; + } } - private function parse_float(Shape $shape, $value) - { - $value = (string) $value; + /** + * Decodes a value whose model-derived metadata has already been resolved. + */ + private function parseResolvedValue( + int $type, + ?XmlDecodePlan $childPlan, + ?string $tsFormat, + int $coerce, + $value + ) { + switch ($type) { + case XmlShapeType::STRUCTURE: + case XmlShapeType::LIST: + case XmlShapeType::MAP: + /** @var XmlDecodePlan $childPlan */ + return $this->parsePlan($childPlan, $value); - return match ($value) { - 'NaN', 'Infinity', '-Infinity' => $value, - default => (float) $value - }; - } + case XmlShapeType::BLOB: + return base64_decode((string) $value); - private function parse_integer(Shape $shape, $value) - { - return (int) (string) $value; - } + case XmlShapeType::BOOLEAN: + return $value == 'true'; - private function parse_boolean(Shape $shape, $value) - { - return $value == 'true'; - } + case XmlShapeType::TIMESTAMP: + return DateTimeResult::fromTimestamp( + (string) $value, + $tsFormat + ); - private function parse_timestamp(Shape $shape, $value) - { - if (is_string($value) - || is_int($value) - || (is_object($value) - && method_exists($value, '__toString')) - ) { - return DateTimeResult::fromTimestamp( - (string) $value, - !empty($shape['timestampFormat']) ? $shape['timestampFormat'] : null - ); + default: // SCALAR: coercion kind precomputed, no model read + if ($coerce === XmlDecodePlan::COERCE_INT) { + return (int) (string) $value; + } + if ($coerce === XmlDecodePlan::COERCE_FLOAT) { + $s = (string) $value; + return match ($s) { + 'NaN', 'Infinity', '-Infinity' => $s, + default => (float) $s, + }; + } + return (string) $value; } - throw new ParserException('Invalid timestamp value passed to XmlParser::parse_timestamp'); } - private function parse_xml_attribute(Shape $shape, Shape $memberShape, $value) + private function readAttribute(string $key, string $namespace, \SimpleXMLElement $value) { - $namespace = $shape['xmlNamespace']['uri'] ?? ''; - $prefix = $shape['xmlNamespace']['prefix'] ?? ''; - if (!empty($prefix)) { - $prefix .= ':'; - } - $key = str_replace($prefix, '', $memberShape['locationName']); - $attributes = $value->attributes($namespace); return isset($attributes[$key]) ? (string) $attributes[$key] : null; } diff --git a/src/Api/Serde/Json/JsonDecodePlan.php b/src/Api/Serde/Json/JsonDecodePlan.php new file mode 100644 index 0000000000..964aa0ab10 --- /dev/null +++ b/src/Api/Serde/Json/JsonDecodePlan.php @@ -0,0 +1,45 @@ +|null Ordered member descriptors for a structure. */ + public $members; + + /** @var array|null Value descriptor for a list or map. */ + public $value; + + /** @var string|null Timestamp format when the root shape is a timestamp. */ + public $timestampFormat; + + /** @var bool Whether the structure is a union (drives Unknown fallback). */ + public $union = false; +} diff --git a/src/Api/Serde/Json/JsonDecodePlanProvider.php b/src/Api/Serde/Json/JsonDecodePlanProvider.php new file mode 100644 index 0000000000..cc99a1a10e --- /dev/null +++ b/src/Api/Serde/Json/JsonDecodePlanProvider.php @@ -0,0 +1,99 @@ +getSerdePlan(ShapePlanCache::JSON_DECODE) + ?? $shape->cacheSerdePlan( + ShapePlanCache::JSON_DECODE, + $this->compile($shape) + ); + } + + private function compile(Shape $shape): JsonDecodePlan + { + $plan = new JsonDecodePlan(); + $plan->type = JsonShapeType::fromShape($shape); + + switch ($plan->type) { + case JsonShapeType::STRUCTURE: + $members = []; + foreach ($shape->getMembers() as $name => $member) { + $type = JsonShapeType::fromShape($member); + $members[] = [ + JsonDecodePlan::M_SDK => $name, + JsonDecodePlan::M_WIRE => $member['locationName'] ?: $name, + JsonDecodePlan::M_TYPE => $type, + JsonDecodePlan::M_SHAPE => $member, + JsonDecodePlan::M_TSFORMAT => self::timestampFormat($type, $member), + ]; + } + $plan->members = $members; + $plan->union = !empty($shape['union']); + break; + + case JsonShapeType::LIST: + $member = $shape->getMember(); + $type = JsonShapeType::fromShape($member); + $plan->value = [ + JsonDecodePlan::V_TYPE => $type, + JsonDecodePlan::V_SHAPE => $member, + JsonDecodePlan::V_TSFORMAT => self::timestampFormat($type, $member), + ]; + break; + + case JsonShapeType::MAP: + $value = $shape->getValue(); + $type = JsonShapeType::fromShape($value); + $plan->value = [ + JsonDecodePlan::V_TYPE => $type, + JsonDecodePlan::V_SHAPE => $value, + JsonDecodePlan::V_TSFORMAT => self::timestampFormat($type, $value), + ]; + break; + + case JsonShapeType::TIMESTAMP: + $plan->timestampFormat = self::timestampFormat($plan->type, $shape); + break; + } + + return $plan; + } + + /** + * Resolves the timestamp format for a shape, or null when it is not a + * timestamp. Matches JsonParser's default of null (DateTimeResult). + */ + private static function timestampFormat(int $type, Shape $shape): ?string + { + if ($type !== JsonShapeType::TIMESTAMP) { + return null; + } + + return !empty($shape['timestampFormat']) + ? $shape['timestampFormat'] + : null; + } +} diff --git a/src/Api/Serde/Json/JsonEncodePlan.php b/src/Api/Serde/Json/JsonEncodePlan.php new file mode 100644 index 0000000000..616f6220e7 --- /dev/null +++ b/src/Api/Serde/Json/JsonEncodePlan.php @@ -0,0 +1,44 @@ +|null Member descriptors for a structure. */ + public $members; + + /** @var array|null Value descriptor for a list or map. */ + public $value; + + /** @var JsonEncodePlan|null Lazily resolved list/map child plan. */ + public $valuePlan; + + /** @var string|null Timestamp format when the root shape is a timestamp. */ + public $timestampFormat; +} diff --git a/src/Api/Serde/Json/JsonEncodePlanProvider.php b/src/Api/Serde/Json/JsonEncodePlanProvider.php new file mode 100644 index 0000000000..e1394ea027 --- /dev/null +++ b/src/Api/Serde/Json/JsonEncodePlanProvider.php @@ -0,0 +1,98 @@ +getSerdePlan(ShapePlanCache::JSON_ENCODE) + ?? $shape->cacheSerdePlan( + ShapePlanCache::JSON_ENCODE, + $this->compile($shape) + ); + } + + private function compile(Shape $shape): JsonEncodePlan + { + $plan = new JsonEncodePlan(); + $plan->type = JsonShapeType::fromShape($shape); + + switch ($plan->type) { + case JsonShapeType::STRUCTURE: + $members = []; + foreach ($shape->getMembers() as $name => $member) { + $type = JsonShapeType::fromShape($member); + $members[$name] = [ + JsonEncodePlan::M_WIRE => $member['locationName'] ?: $name, + JsonEncodePlan::M_TYPE => $type, + JsonEncodePlan::M_SHAPE => $member, + JsonEncodePlan::M_TSFORMAT => self::timestampFormat($type, $member), + JsonEncodePlan::M_PLAN => null, + ]; + } + $plan->members = $members; + break; + + case JsonShapeType::LIST: + $member = $shape->getMember(); + $type = JsonShapeType::fromShape($member); + $plan->value = [ + JsonEncodePlan::V_TYPE => $type, + JsonEncodePlan::V_SHAPE => $member, + JsonEncodePlan::V_TSFORMAT => self::timestampFormat($type, $member), + ]; + break; + + case JsonShapeType::MAP: + $value = $shape->getValue(); + $type = JsonShapeType::fromShape($value); + $plan->value = [ + JsonEncodePlan::V_TYPE => $type, + JsonEncodePlan::V_SHAPE => $value, + JsonEncodePlan::V_TSFORMAT => self::timestampFormat($type, $value), + ]; + break; + + case JsonShapeType::TIMESTAMP: + $plan->timestampFormat = self::timestampFormat($plan->type, $shape); + break; + } + + return $plan; + } + + /** + * Resolves the timestamp format for a shape, or null when it is not a + * timestamp. Defaults to unixTimestamp. + */ + private static function timestampFormat(int $type, Shape $shape): ?string + { + if ($type !== JsonShapeType::TIMESTAMP) { + return null; + } + + return !empty($shape['timestampFormat']) + ? $shape['timestampFormat'] + : 'unixTimestamp'; + } +} diff --git a/src/Api/Serde/Json/JsonShapeType.php b/src/Api/Serde/Json/JsonShapeType.php new file mode 100644 index 0000000000..72a9d40b7b --- /dev/null +++ b/src/Api/Serde/Json/JsonShapeType.php @@ -0,0 +1,50 @@ +{node}) + public const M_TYPE = 2; // XmlShapeType tag + public const M_SHAPE = 3; // child Shape, for lazy composite plan lookup + public const M_ATTRIBUTE = 4; // bool: fall back to an attribute when absent + public const M_ATTRKEY = 5; // attribute key (locationName minus prefix), or null + public const M_ATTRNS = 6; // attribute namespace uri, or '' + public const M_TSFORMAT = 7; // timestamp format, or null + public const M_COERCE = 8; // scalar coercion kind (COERCE_*) + public const M_PLAN = 9; // lazily resolved composite child plan + + /** @var int XmlShapeType tag for the shape this plan decodes. */ + public $type; + + /** @var array|null Ordered member descriptors for a structure. */ + public $members; + + /** @var bool Whether the structure is a union (drives Unknown fallback). */ + public $union = false; + + // --- List fields --- + /** @var bool Whether a list/map is flattened (no wrapper element). */ + public $flattened = false; + /** @var string|null Wrapper/item element name for wrapped lists. */ + public $listItemName; + /** @var int|null XmlShapeType tag of the list element. */ + public $listItemType; + /** @var \Aws\Api\Shape|null List element Shape. */ + public $listItemShape; + /** @var string|null Timestamp format for a list of timestamps. */ + public $listItemTsFormat; + /** @var int Scalar coercion kind for a scalar list element. */ + public $listItemCoerce = self::COERCE_STRING; + /** @var XmlDecodePlan|null Lazily resolved composite list item plan. */ + public $listItemPlan; + + // --- Map fields --- + /** @var string|null Key element name ('key' default). */ + public $mapKeyName; + /** @var string|null Value element name ('value' default). */ + public $mapValueName; + /** @var int|null XmlShapeType tag of the map key. */ + public $mapKeyType; + /** @var int|null XmlShapeType tag of the map value. */ + public $mapValueType; + /** @var \Aws\Api\Shape|null Map key Shape. */ + public $mapKeyShape; + /** @var \Aws\Api\Shape|null Map value Shape. */ + public $mapValueShape; + /** @var string|null Timestamp format for a timestamp map value. */ + public $mapValueTsFormat; + /** @var int Scalar coercion kind for a scalar map key. */ + public $mapKeyCoerce = self::COERCE_STRING; + /** @var int Scalar coercion kind for a scalar map value. */ + public $mapValueCoerce = self::COERCE_STRING; + /** @var XmlDecodePlan|null Lazily resolved composite map key plan. */ + public $mapKeyPlan; + /** @var XmlDecodePlan|null Lazily resolved composite map value plan. */ + public $mapValuePlan; + + /** @var string|null Timestamp format when the shape is a timestamp. */ + public $timestampFormat; +} diff --git a/src/Api/Serde/Xml/XmlDecodePlanProvider.php b/src/Api/Serde/Xml/XmlDecodePlanProvider.php new file mode 100644 index 0000000000..20bef0df77 --- /dev/null +++ b/src/Api/Serde/Xml/XmlDecodePlanProvider.php @@ -0,0 +1,167 @@ +getSerdePlan(ShapePlanCache::XML_DECODE) + ?? $shape->cacheSerdePlan( + ShapePlanCache::XML_DECODE, + $this->compile($shape) + ); + } + + private function compile(Shape $shape): XmlDecodePlan + { + $plan = new XmlDecodePlan(); + $plan->type = XmlShapeType::fromShape($shape); + + switch ($plan->type) { + case XmlShapeType::STRUCTURE: + /** @var StructureShape $shape */ + $this->compileStructureMembers($shape, $plan); + break; + + case XmlShapeType::LIST: + /** @var ListShape $shape */ + $item = $shape->getMember(); + $plan->flattened = (bool) $shape['flattened']; + $plan->listItemName = $item['locationName'] ?: 'member'; + $plan->listItemType = XmlShapeType::fromShape($item); + $plan->listItemShape = $item; + $plan->listItemTsFormat = self::timestampFormat($item); + $plan->listItemCoerce = self::coercionKind($item); + break; + + case XmlShapeType::MAP: + /** @var MapShape $shape */ + $key = $shape->getKey(); + $value = $shape->getValue(); + $plan->flattened = (bool) $shape['flattened']; + $plan->mapKeyName = $key['locationName'] ?: 'key'; + $plan->mapValueName = $value['locationName'] ?: 'value'; + $plan->mapKeyType = XmlShapeType::fromShape($key); + $plan->mapValueType = XmlShapeType::fromShape($value); + $plan->mapKeyShape = $key; + $plan->mapValueShape = $value; + $plan->mapValueTsFormat = self::timestampFormat($value); + $plan->mapKeyCoerce = self::coercionKind($key); + $plan->mapValueCoerce = self::coercionKind($value); + break; + + case XmlShapeType::TIMESTAMP: + $plan->timestampFormat = self::timestampFormat($shape); + break; + } + + return $plan; + } + + private function compileStructureMembers(StructureShape $shape, XmlDecodePlan $plan): void + { + $members = []; + $ns = $shape['xmlNamespace']; + $nsUri = $ns['uri'] ?? ''; + $nsPrefix = isset($ns['prefix']) ? $ns['prefix'] . ':' : ''; + + foreach ($shape->getMembers() as $name => $member) { + $isAttribute = !empty($member['xmlAttribute']); + + $members[] = [ + XmlDecodePlan::M_SDK => $name, + XmlDecodePlan::M_NODE => $this->memberNode($member, $name), + XmlDecodePlan::M_TYPE => XmlShapeType::fromShape($member), + XmlDecodePlan::M_SHAPE => $member, + XmlDecodePlan::M_ATTRIBUTE => $isAttribute, + XmlDecodePlan::M_ATTRKEY => $isAttribute + ? str_replace($nsPrefix, '', $member['locationName'] ?: $name) + : null, + XmlDecodePlan::M_ATTRNS => $nsUri, + XmlDecodePlan::M_TSFORMAT => self::timestampFormat($member), + XmlDecodePlan::M_COERCE => self::coercionKind($member), + XmlDecodePlan::M_PLAN => null, + ]; + } + + $plan->members = $members; + $plan->union = !empty($shape['union']); + } + + /** + * Resolves the element name to read for a structure member, including the + * getOriginalDefinition special case: + * a StructureShape member whose locationName was inherited from the target + * shape definition (not declared at member level) reads by member name. + */ + private function memberNode(Shape $member, string $name): string + { + if ($member instanceof StructureShape && isset($member['locationName'])) { + $originalDef = $member->getOriginalDefinition($member->getName()); + // $member['locationName'] is set (checked above), so a missing + // original locationName never matches. + if (($originalDef['locationName'] ?? null) === $member['locationName']) { + return $name; + } + } + + return $member['locationName'] ?? $name; + } + + /** + * Precomputes the scalar coercion kind so the hot path never re-reads the + * model type for a leaf. Non-scalars return COERCE_STRING (unused). + */ + private static function coercionKind(Shape $shape): int + { + switch ($shape['type']) { + case 'integer': + return XmlDecodePlan::COERCE_INT; + case 'float': + case 'double': + return XmlDecodePlan::COERCE_FLOAT; + default: + return XmlDecodePlan::COERCE_STRING; + } + } + + /** + * Timestamp decode format, or null. Matches XmlParser's decode default of + * null (DateTimeResult auto-detects), NOT the encode default iso8601. + */ + private static function timestampFormat(Shape $shape): ?string + { + if (XmlShapeType::fromShape($shape) !== XmlShapeType::TIMESTAMP) { + return null; + } + + return !empty($shape['timestampFormat']) + ? $shape['timestampFormat'] + : null; + } +} diff --git a/src/Api/Serde/Xml/XmlEncodePlan.php b/src/Api/Serde/Xml/XmlEncodePlan.php new file mode 100644 index 0000000000..04538f5a91 --- /dev/null +++ b/src/Api/Serde/Xml/XmlEncodePlan.php @@ -0,0 +1,117 @@ +|null Member descriptors keyed by SDK name. */ + public $members; + + /** @var array|null SDK names of xmlAttribute members (emit first). */ + public $attributeMembers; + + // --- List fields --- + /** @var bool Whether a list/map is flattened (no wrapper element). */ + public $flattened = false; + /** @var int|null XmlShapeType tag of the list element. */ + public $listItemType; + /** @var \Aws\Api\Shape|null The list element Shape. */ + public $listItemShape; + /** @var string|null Element name for wrapped list items ('member' default). */ + public $listItemName; + /** @var array{0:string,1:string}|null Namespace attr for list items. */ + public $listItemNs; + /** @var bool Whether list items are written as attributes (string only). */ + public $listItemAttribute = false; + /** @var string|null Item locationName used as the attribute name. */ + public $listItemAttrName; + /** @var string|null Timestamp format for timestamp list items. */ + public $listItemTimestampFormat; + /** @var XmlEncodePlan|null Lazily resolved composite list item plan. */ + public $listItemPlan; + + // --- Map fields --- + /** @var string|null Entry wrapper element name ('entry' when not flat). */ + public $mapEntryName; + /** @var string|null Key element name ('key' default). */ + public $mapKeyName; + /** @var string|null Value element name ('value' default). */ + public $mapValueName; + /** @var \Aws\Api\Shape|null Map key Shape. */ + public $mapKeyShape; + /** @var \Aws\Api\Shape|null Map value Shape. */ + public $mapValueShape; + /** @var int|null XmlShapeType tag of the map value. */ + public $mapValueType; + /** @var int|null XmlShapeType tag of the map key. */ + public $mapKeyType; + /** @var array{0:string,1:string}|null Namespace attr for the entry element. */ + public $mapEntryNs; + /** @var array{0:string,1:string}|null Namespace attr for map keys. */ + public $mapKeyNs; + /** @var array{0:string,1:string}|null Namespace attr for map values. */ + public $mapValueNs; + /** @var bool Whether map keys are written as attributes (string only). */ + public $mapKeyAttribute = false; + /** @var bool Whether map values are written as attributes (string only). */ + public $mapValueAttribute = false; + /** @var string|null Timestamp format for timestamp map keys. */ + public $mapKeyTimestampFormat; + /** @var string|null Timestamp format for timestamp map values. */ + public $mapValueTimestampFormat; + /** @var XmlEncodePlan|null Lazily resolved composite map key plan. */ + public $mapKeyPlan; + /** @var XmlEncodePlan|null Lazily resolved composite map value plan. */ + public $mapValuePlan; + + /** @var string|null Timestamp format when the shape is a timestamp. */ + public $timestampFormat; +} diff --git a/src/Api/Serde/Xml/XmlEncodePlanProvider.php b/src/Api/Serde/Xml/XmlEncodePlanProvider.php new file mode 100644 index 0000000000..80760a5505 --- /dev/null +++ b/src/Api/Serde/Xml/XmlEncodePlanProvider.php @@ -0,0 +1,226 @@ +getSerdePlan(ShapePlanCache::XML_ENCODE) + ?? $shape->cacheSerdePlan( + ShapePlanCache::XML_ENCODE, + $this->compile($shape) + ); + } + + private function compile(Shape $shape): XmlEncodePlan + { + $plan = new XmlEncodePlan(); + $plan->type = XmlShapeType::fromShape($shape); + $plan->namespace = self::namespaceAttribute($shape); + + switch ($plan->type) { + case XmlShapeType::STRUCTURE: + /** @var StructureShape $shape */ + $this->compileStructureMembers($shape, $plan); + break; + + case XmlShapeType::LIST: + /** @var ListShape $shape */ + $item = $shape->getMember(); + $plan->flattened = (bool) $shape['flattened']; + $plan->listItemType = XmlShapeType::fromShape($item); + $plan->listItemShape = $item; + // Wrapped lists name each item by member locationName or 'member'; + // flattened lists reuse the list's own element name at runtime. + $plan->listItemName = $item['locationName'] ?: 'member'; + $plan->listItemNs = self::namespaceAttribute($item); + $plan->listItemAttribute = self::writesAttribute($item); + $plan->listItemAttrName = $item['locationName'] ?: null; + $plan->listItemTimestampFormat = self::timestampFormatForType( + $plan->listItemType, + $item + ); + break; + + case XmlShapeType::MAP: + /** @var MapShape $shape */ + $key = $shape->getKey(); + $value = $shape->getValue(); + $plan->flattened = (bool) $shape['flattened']; + $plan->mapEntryName = $plan->flattened ? null : 'entry'; + $plan->mapKeyName = $key['locationName'] ?: 'key'; + $plan->mapValueName = $value['locationName'] ?: 'value'; + $plan->mapKeyShape = $key; + $plan->mapValueShape = $value; + $plan->mapKeyType = XmlShapeType::fromShape($key); + $plan->mapValueType = XmlShapeType::fromShape($value); + $plan->mapKeyNs = self::namespaceAttribute($key); + $plan->mapValueNs = self::namespaceAttribute($value); + $plan->mapKeyAttribute = self::writesAttribute($key); + $plan->mapValueAttribute = self::writesAttribute($value); + $plan->mapKeyTimestampFormat = self::timestampFormatForType( + $plan->mapKeyType, + $key + ); + $plan->mapValueTimestampFormat = self::timestampFormatForType( + $plan->mapValueType, + $value + ); + // Each entry element is opened with the map shape's namespace. + $plan->mapEntryNs = $plan->namespace; + break; + + case XmlShapeType::TIMESTAMP: + $plan->timestampFormat = self::timestampFormat($shape); + break; + } + + return $plan; + } + + /** + * Precomputes member descriptors keyed by SDK name, plus the list of + * xmlAttribute member names (emitted first). Element name resolution and + * the child namespace attribute are precomputed per member. XmlBody derives + * these on every request; here they are computed once. + */ + private function compileStructureMembers(StructureShape $shape, XmlEncodePlan $plan): void + { + $members = []; + $attributeMembers = []; + + foreach ($shape->getMembers() as $name => $member) { + $isAttribute = (bool) $member['xmlAttribute']; + $type = XmlShapeType::fromShape($member); + + $elementName = $name; + if ($member['locationName'] && !isset($member['locationNameAtStructureLevel'])) { + $elementName = $member['locationName']; + } + + $members[$name] = [ + XmlEncodePlan::M_ELEMENT => $elementName, + XmlEncodePlan::M_TYPE => $type, + XmlEncodePlan::M_SHAPE => $member, + XmlEncodePlan::M_ATTRIBUTE => $isAttribute, + XmlEncodePlan::M_NS => self::namespaceAttribute($member), + XmlEncodePlan::M_ATTR_NAME => self::writesAttribute($member) + ? ($member['locationName'] ?: $elementName) + : null, + XmlEncodePlan::M_TSFORMAT => self::timestampFormatForType( + $type, + $member + ), + XmlEncodePlan::M_PLAN => null, + ]; + + if ($isAttribute) { + $attributeMembers[] = $name; + } + } + + $plan->members = $members; + $plan->attributeMembers = $attributeMembers; + } + + /** + * Resolves the root element name using a three-level precedence: + * 1. the original ShapeMap target definition's locationName + * 2. the resolved shape's locationName + * 3. the modeled shape name + * + * Called by XmlBody only for the shape it is asked to build, never for + * nested shapes, which may be inline and carry no name. + */ + public static function rootElementName(Shape $shape): string + { + $shapeName = $shape->getName(); + + if ($shapeName && $shape instanceof StructureShape) { + $original = $shape->getOriginalDefinition($shapeName); + if (isset($original['locationName'])) { + return $original['locationName']; + } + } + + if ($shape['locationName']) { + return $shape['locationName']; + } + + return (string) $shapeName; + } + + /** + * Whether XmlBody writes this shape as an attribute. Only string shapes + * honor xmlAttribute; every other type is written as an element even when + * the model marks it xmlAttribute. + */ + private static function writesAttribute(Shape $shape): bool + { + return $shape['type'] === 'string' && $shape['xmlAttribute']; + } + + /** + * Precomputes the xmlns attribute for a shape, or null when absent. + * + * @return array{0:string,1:string}|null [attributeName, uri] + */ + private static function namespaceAttribute(Shape $shape): ?array + { + $ns = $shape['xmlNamespace']; + if (!$ns) { + return null; + } + + $name = isset($ns['prefix']) ? "xmlns:{$ns['prefix']}" : 'xmlns'; + + return [$name, $ns['uri']]; + } + + /** + * Resolves the timestamp format for a shape. Matches XmlBody's default of + * iso8601 (not JSON's unixTimestamp). + */ + private static function timestampFormat(Shape $shape): string + { + return !empty($shape['timestampFormat']) + ? $shape['timestampFormat'] + : 'iso8601'; + } + + /** + * Resolves the timestamp format only when the shape is a timestamp. + */ + private static function timestampFormatForType(int $type, Shape $shape): ?string + { + return $type === XmlShapeType::TIMESTAMP + ? self::timestampFormat($shape) + : null; + } +} diff --git a/src/Api/Serde/Xml/XmlShapeType.php b/src/Api/Serde/Xml/XmlShapeType.php new file mode 100644 index 0000000000..06ea942c75 --- /dev/null +++ b/src/Api/Serde/Xml/XmlShapeType.php @@ -0,0 +1,54 @@ +api = $api; + $this->planProvider = new JsonEncodePlanProvider(); } /** @@ -51,8 +58,13 @@ public static function getContentType(Service $service) */ public function build(Shape $shape, array|string $args) { + if ($args === []) { + return '{}'; + } + try { - $result = json_encode($this->format($shape, $args), JSON_THROW_ON_ERROR); + $plan = $this->planProvider->get($shape); + $result = json_encode($this->formatPlan($plan, $args), JSON_THROW_ON_ERROR); } catch (\JsonException $e) { throw new InvalidJsonException( 'Unable to encode JSON document ' . $shape->getName() . ': ' . @@ -63,53 +75,126 @@ public function build(Shape $shape, array|string $args) return $result === '[]' ? '{}' : $result; } - private function format(Shape $shape, $value) + /** + * Encodes a value using a compiled plan instead of re-reading the model. + */ + private function formatPlan(JsonEncodePlan $plan, $value) { - switch ($shape['type']) { - case 'structure': + switch ($plan->type) { + case JsonShapeType::STRUCTURE: $data = []; - if ($shape['document'] ?? false) { - return $value; - } foreach ($value as $k => $v) { - if ($v !== null && $shape->hasMember($k)) { - $valueShape = $shape->getMember($k); - $data[$valueShape['locationName'] ?: $k] - = $this->format($valueShape, $v); + if ($v === null || !isset($plan->members[$k])) { + continue; + } + $member = $plan->members[$k]; + switch ($member[JsonEncodePlan::M_TYPE]) { + case JsonShapeType::STRUCTURE: + case JsonShapeType::LIST: + case JsonShapeType::MAP: + $childPlan = $member[JsonEncodePlan::M_PLAN]; + if ($childPlan === null) { + $childPlan = $this->planProvider->get( + $member[JsonEncodePlan::M_SHAPE] + ); + $plan->members[$k][JsonEncodePlan::M_PLAN] = $childPlan; + } + $formatted = $this->formatPlan($childPlan, $v); + break; + + case JsonShapeType::BLOB: + $formatted = base64_encode($v); + break; + + case JsonShapeType::TIMESTAMP: + $formatted = TimestampShape::format( + $v, + $member[JsonEncodePlan::M_TSFORMAT] + ); + break; + + default: // SCALAR, DOCUMENT + $formatted = $v; } + $data[$member[JsonEncodePlan::M_WIRE]] = $formatted; } if (empty($data)) { - return new \stdClass; + return new \stdClass(); } return $data; - case 'list': - $items = $shape->getMember(); - foreach ($value as $k => $v) { - $value[$k] = $this->format($items, $v); + case JsonShapeType::LIST: + $type = $plan->value[JsonEncodePlan::V_TYPE]; + $shape = $plan->value[JsonEncodePlan::V_SHAPE]; + $ts = $plan->value[JsonEncodePlan::V_TSFORMAT]; + switch ($type) { + case JsonShapeType::STRUCTURE: + case JsonShapeType::LIST: + case JsonShapeType::MAP: + $childPlan = $plan->valuePlan + ??= $this->planProvider->get($shape); + foreach ($value as $k => $v) { + $value[$k] = $this->formatPlan($childPlan, $v); + } + return $value; + + case JsonShapeType::BLOB: + foreach ($value as $k => $v) { + $value[$k] = base64_encode($v); + } + return $value; + + case JsonShapeType::TIMESTAMP: + foreach ($value as $k => $v) { + $value[$k] = TimestampShape::format($v, $ts); + } + return $value; + + default: // SCALAR, DOCUMENT + return $value; } - return $value; - case 'map': + case JsonShapeType::MAP: if (empty($value)) { - return new \stdClass; + return new \stdClass(); } - $values = $shape->getValue(); - foreach ($value as $k => $v) { - $value[$k] = $this->format($values, $v); + $type = $plan->value[JsonEncodePlan::V_TYPE]; + $shape = $plan->value[JsonEncodePlan::V_SHAPE]; + $ts = $plan->value[JsonEncodePlan::V_TSFORMAT]; + switch ($type) { + case JsonShapeType::STRUCTURE: + case JsonShapeType::LIST: + case JsonShapeType::MAP: + $childPlan = $plan->valuePlan + ??= $this->planProvider->get($shape); + foreach ($value as $k => $v) { + $value[$k] = $this->formatPlan($childPlan, $v); + } + return $value; + + case JsonShapeType::BLOB: + foreach ($value as $k => $v) { + $value[$k] = base64_encode($v); + } + return $value; + + case JsonShapeType::TIMESTAMP: + foreach ($value as $k => $v) { + $value[$k] = TimestampShape::format($v, $ts); + } + return $value; + + default: // SCALAR, DOCUMENT + return $value; } - return $value; - case 'blob': + case JsonShapeType::BLOB: return base64_encode($value); - case 'timestamp': - $timestampFormat = !empty($shape['timestampFormat']) - ? $shape['timestampFormat'] - : 'unixTimestamp'; - return TimestampShape::format($value, $timestampFormat); + case JsonShapeType::TIMESTAMP: + return TimestampShape::format($value, $plan->timestampFormat); - default: + default: // SCALAR, DOCUMENT return $value; } } diff --git a/src/Api/Serializer/XmlBody.php b/src/Api/Serializer/XmlBody.php index 47100e2bea..5622113d7d 100644 --- a/src/Api/Serializer/XmlBody.php +++ b/src/Api/Serializer/XmlBody.php @@ -1,11 +1,11 @@ api = $api; + $this->planProvider = new XmlEncodePlanProvider(); } /** @@ -39,218 +43,426 @@ public function build(Shape $shape, array $args) $xml->openMemory(); $xml->startDocument('1.0', 'UTF-8'); - $rootElementName = $this->determineRootElementName($shape); + $plan = $this->planProvider->get($shape); + $plan->rootName ??= XmlEncodePlanProvider::rootElementName($shape); - $this->format($shape, $rootElementName, $args, $xml); + $this->formatPlan($plan, $plan->rootName, $args, $xml); $xml->endDocument(); return $xml->outputMemory(); } - private function startElement(Shape $shape, $name, XMLWriter $xml) - { - $xml->startElement($name); - - if ($ns = $shape['xmlNamespace']) { - $xml->writeAttribute( - isset($ns['prefix']) ? "xmlns:{$ns['prefix']}" : 'xmlns', - $ns['uri'] - ); - } - } - - private function format(Shape $shape, $name, $value, XMLWriter $xml) - { - // Any method mentioned here has a custom serialization handler. - static $methods = [ - 'add_structure' => true, - 'add_list' => true, - 'add_blob' => true, - 'add_timestamp' => true, - 'add_boolean' => true, - 'add_map' => true, - 'add_string' => true - ]; - - $type = 'add_' . $shape['type']; - if (isset($methods[$type])) { - $this->{$type}($shape, $name, $value, $xml); - } else { - $this->defaultShape($shape, $name, $value, $xml); - } - } - - private function defaultShape(Shape $shape, $name, $value, XMLWriter $xml) - { - $this->startElement($shape, $name, $xml); - $xml->text($value); - $xml->endElement(); - } - - private function add_structure( - StructureShape $shape, + /** + * Encodes a value using a compiled plan instead of re-reading the model. + */ + private function formatPlan( + XmlEncodePlan $plan, $name, - array $value, - \XMLWriter $xml + $value, + XMLWriter $xml ) { - $this->startElement($shape, $name, $xml); - - foreach ($this->getStructureMembers($shape, $value) as $k => $definition) { - // Default to member name - $elementName = $k; - - if ($definition['member']['locationName'] - && !isset($definition['member']['locationNameAtStructureLevel'])) { - $elementName = $definition['member']['locationName']; - } - - $this->format( - $definition['member'], - $elementName, - $definition['value'], - $xml - ); - } - - $xml->endElement(); - } - - private function getStructureMembers(StructureShape $shape, array $value) - { - $members = []; - - foreach ($value as $k => $v) { - if ($v !== null && $shape->hasMember($k)) { - $definition = [ - 'member' => $shape->getMember($k), - 'value' => $v, - ]; - - if ($definition['member']['xmlAttribute']) { - // array_unshift_associative - $members = [$k => $definition] + $members; + switch ($plan->type) { + case XmlShapeType::STRUCTURE: + $this->startElementPlan($plan, $name, $xml); + + if (empty($plan->attributeMembers)) { + foreach ($value as $k => $v) { + if ($v !== null && isset($plan->members[$k])) { + $this->formatStructureMember($plan, $k, $v, $xml); + } + } } else { - $members[$k] = $definition; + // Preserve legacy ordering: modeled xmlAttribute members + // are prepended in reverse caller order, including + // non-string members that are ultimately written as + // elements. + $attributeValues = []; + foreach ($value as $k => $v) { + if ($v !== null + && isset($plan->members[$k]) + && $plan->members[$k][XmlEncodePlan::M_ATTRIBUTE] + ) { + $attributeValues[] = [$k, $v]; + } + } + for ($i = count($attributeValues) - 1; $i >= 0; $i--) { + $this->formatStructureMember( + $plan, + $attributeValues[$i][0], + $attributeValues[$i][1], + $xml + ); + } + foreach ($value as $k => $v) { + if ($v !== null + && isset($plan->members[$k]) + && !$plan->members[$k][XmlEncodePlan::M_ATTRIBUTE] + ) { + $this->formatStructureMember($plan, $k, $v, $xml); + } + } } - } - } - - return $members; - } - - private function add_list( - ListShape $shape, - $name, - array $value, - XMLWriter $xml - ) { - $items = $shape->getMember(); - - if ($shape['flattened']) { - $elementName = $name; - } else { - $this->startElement($shape, $name, $xml); - $elementName = $items['locationName'] ?: 'member'; - } - foreach ($value as $v) { - $this->format($items, $elementName, $v, $xml); - } + $xml->endElement(); + break; - if (!$shape['flattened']) { - $xml->endElement(); - } - } + case XmlShapeType::LIST: + if ($plan->flattened) { + $elementName = $name; + } else { + $this->startElementPlan($plan, $name, $xml); + $elementName = $plan->listItemName; + } + $itemAttrName = $plan->listItemAttribute + ? ($plan->listItemAttrName ?? $elementName) + : null; + + switch ($plan->listItemType) { + case XmlShapeType::STRUCTURE: + case XmlShapeType::LIST: + case XmlShapeType::MAP: + $childPlan = $plan->listItemPlan + ??= $this->planProvider->get($plan->listItemShape); + foreach ($value as $v) { + $this->formatPlan($childPlan, $elementName, $v, $xml); + } + break; + + case XmlShapeType::BLOB: + foreach ($value as $v) { + $this->writeLeafValue( + $elementName, + base64_encode($v), + $plan->listItemNs, + true, + $xml + ); + } + break; + + case XmlShapeType::TIMESTAMP: + foreach ($value as $v) { + $this->writeLeafValue( + $elementName, + TimestampShape::formatAsString( + $v, + $plan->listItemTimestampFormat + ), + $plan->listItemNs, + true, + $xml + ); + } + break; + + case XmlShapeType::BOOLEAN: + foreach ($value as $v) { + $this->writeLeafValue( + $elementName, + $v ? 'true' : 'false', + $plan->listItemNs, + true, + $xml + ); + } + break; + + default: // SCALAR + if ($itemAttrName !== null) { + foreach ($value as $v) { + $xml->writeAttribute($itemAttrName, $v); + } + } else { + foreach ($value as $v) { + $this->writeLeafValue( + $elementName, + $v, + $plan->listItemNs, + false, + $xml + ); + } + } + } - private function add_map( - MapShape $shape, - $name, - array $value, - XMLWriter $xml - ) { - $xmlEntry = $shape['flattened'] ? $name : 'entry'; - $xmlKey = $shape->getKey()['locationName'] ?: 'key'; - $xmlValue = $shape->getValue()['locationName'] ?: 'value'; + if (!$plan->flattened) { + $xml->endElement(); + } + break; - if (!$shape['flattened']) { - $this->startElement($shape, $name, $xml); - } + case XmlShapeType::MAP: + $entryName = $plan->flattened ? $name : $plan->mapEntryName; + if (!$plan->flattened) { + $this->startElementPlan($plan, $name, $xml); + } - foreach ($value as $key => $v) { - $this->startElement($shape, $xmlEntry, $xml); - $this->format($shape->getKey(), $xmlKey, $key, $xml); - $this->format($shape->getValue(), $xmlValue, $v, $xml); - $xml->endElement(); - } + $keyPlan = null; + if ($plan->mapKeyType >= XmlShapeType::STRUCTURE + && $plan->mapKeyType <= XmlShapeType::MAP + ) { + $keyPlan = $plan->mapKeyPlan + ??= $this->planProvider->get($plan->mapKeyShape); + } + $valuePlan = null; + if ($plan->mapValueType >= XmlShapeType::STRUCTURE + && $plan->mapValueType <= XmlShapeType::MAP + ) { + $valuePlan = $plan->mapValuePlan + ??= $this->planProvider->get($plan->mapValueShape); + } - if (!$shape['flattened']) { - $xml->endElement(); + foreach ($value as $key => $v) { + $this->openLeaf($entryName, $plan->mapEntryNs, $xml); + $this->formatResolvedValue( + $plan->mapKeyType, + $keyPlan, + $plan->mapKeyName, + $key, + $plan->mapKeyAttribute ? $plan->mapKeyName : null, + $plan->mapKeyNs, + $plan->mapKeyTimestampFormat, + $xml + ); + $this->formatResolvedValue( + $plan->mapValueType, + $valuePlan, + $plan->mapValueName, + $v, + $plan->mapValueAttribute ? $plan->mapValueName : null, + $plan->mapValueNs, + $plan->mapValueTimestampFormat, + $xml + ); + $xml->endElement(); + } + if (!$plan->flattened) { + $xml->endElement(); + } + break; + + case XmlShapeType::BLOB: + $this->writeLeafValue( + $name, + base64_encode($value), + $plan->namespace, + true, + $xml + ); + break; + + case XmlShapeType::TIMESTAMP: + $this->writeLeafValue( + $name, + TimestampShape::formatAsString( + $value, + $plan->timestampFormat + ), + $plan->namespace, + true, + $xml + ); + break; + + case XmlShapeType::BOOLEAN: + $this->writeLeafValue( + $name, + $value ? 'true' : 'false', + $plan->namespace, + true, + $xml + ); + break; + + default: // SCALAR + $this->writeLeafValue( + $name, + $value, + $plan->namespace, + false, + $xml + ); } } - private function add_blob(Shape $shape, $name, $value, XMLWriter $xml) + /** + * Opens an element and writes the shape's precomputed namespace attribute. + */ + private function startElementPlan(XmlEncodePlan $plan, $name, XMLWriter $xml) { - $this->startElement($shape, $name, $xml); - $xml->writeRaw(base64_encode($value)); - $xml->endElement(); + $xml->startElement($name); + if ($plan->namespace !== null) { + $xml->writeAttribute($plan->namespace[0], $plan->namespace[1]); + } } - private function add_timestamp( - TimestampShape $shape, - $name, + private function formatStructureMember( + XmlEncodePlan $plan, + $key, $value, XMLWriter $xml ) { - $this->startElement($shape, $name, $xml); - $timestampFormat = !empty($shape['timestampFormat']) - ? $shape['timestampFormat'] - : 'iso8601'; - $xml->writeRaw( - TimestampShape::formatAsString($value, $timestampFormat) - ); - $xml->endElement(); + $member = $plan->members[$key]; + $type = $member[XmlEncodePlan::M_TYPE]; + $name = $member[XmlEncodePlan::M_ELEMENT]; + $namespace = $member[XmlEncodePlan::M_NS]; + + switch ($type) { + case XmlShapeType::STRUCTURE: + case XmlShapeType::LIST: + case XmlShapeType::MAP: + $childPlan = $member[XmlEncodePlan::M_PLAN]; + if ($childPlan === null) { + $childPlan = $this->planProvider->get( + $member[XmlEncodePlan::M_SHAPE] + ); + $plan->members[$key][XmlEncodePlan::M_PLAN] = $childPlan; + } + $this->formatPlan($childPlan, $name, $value, $xml); + return; + + case XmlShapeType::BLOB: + $this->writeLeafValue( + $name, + base64_encode($value), + $namespace, + true, + $xml + ); + return; + + case XmlShapeType::TIMESTAMP: + $this->writeLeafValue( + $name, + TimestampShape::formatAsString( + $value, + $member[XmlEncodePlan::M_TSFORMAT] + ), + $namespace, + true, + $xml + ); + return; + + case XmlShapeType::BOOLEAN: + $this->writeLeafValue( + $name, + $value ? 'true' : 'false', + $namespace, + true, + $xml + ); + return; + + default: // SCALAR + if ($member[XmlEncodePlan::M_ATTR_NAME] !== null) { + $xml->writeAttribute( + $member[XmlEncodePlan::M_ATTR_NAME], + $value + ); + } else { + $this->writeLeafValue( + $name, + $value, + $namespace, + false, + $xml + ); + } + } } - private function add_boolean( - Shape $shape, + /** + * Formats a value whose model-derived metadata has already been resolved. + */ + private function formatResolvedValue( + int $type, + ?XmlEncodePlan $childPlan, $name, $value, + ?string $attributeName, + ?array $ns, + ?string $timestampFormat, XMLWriter $xml ) { - $this->startElement($shape, $name, $xml); - $xml->writeRaw($value ? 'true' : 'false'); - $xml->endElement(); + switch ($type) { + case XmlShapeType::STRUCTURE: + case XmlShapeType::LIST: + case XmlShapeType::MAP: + /** @var XmlEncodePlan $childPlan */ + $this->formatPlan($childPlan, $name, $value, $xml); + return; + + case XmlShapeType::BLOB: + $this->writeLeafValue( + $name, + base64_encode($value), + $ns, + true, + $xml + ); + return; + + case XmlShapeType::TIMESTAMP: + $this->writeLeafValue( + $name, + TimestampShape::formatAsString($value, $timestampFormat), + $ns, + true, + $xml + ); + return; + + case XmlShapeType::BOOLEAN: + $this->writeLeafValue( + $name, + $value ? 'true' : 'false', + $ns, + true, + $xml + ); + return; + + default: // SCALAR + if ($attributeName !== null) { + $xml->writeAttribute($attributeName, $value); + } else { + $this->writeLeafValue($name, $value, $ns, false, $xml); + } + } } - private function add_string( - Shape $shape, + /** + * Writes a leaf with a single XMLWriter call when no namespace is needed. + */ + private function writeLeafValue( $name, $value, + ?array $ns, + bool $raw, XMLWriter $xml ) { - if ($shape['xmlAttribute']) { - $xml->writeAttribute($shape['locationName'] ?: $name, $value); + if ($ns === null) { + $xml->writeElement($name, (string) $value); + return; + } + + $this->openLeaf($name, $ns, $xml); + if ($raw) { + $xml->writeRaw((string) $value); } else { - $this->defaultShape($shape, $name, $value, $xml); + $xml->text($value); } + $xml->endElement(); } - private function determineRootElementName(Shape $shape): string + /** + * Opens a leaf element, writing its precomputed namespace attribute if any. + */ + private function openLeaf($name, ?array $ns, XMLWriter $xml) { - $shapeName = $shape->getName(); - - // Look up the shape definition first - if ($shapeName && $shapeMap = $shape->getShapeMap()) { - if (isset($shapeMap[$shapeName]['locationName'])) { - return $shapeMap[$shapeName]['locationName']; - } - } - - // Fall back to shape's current locationName - if ($shape['locationName']) { - return $shape['locationName']; + $xml->startElement($name); + if ($ns !== null) { + $xml->writeAttribute($ns[0], $ns[1]); } - - return $shapeName; } } diff --git a/src/Api/Service.php b/src/Api/Service.php index 2d080755cd..ca513cf52c 100644 --- a/src/Api/Service.php +++ b/src/Api/Service.php @@ -70,7 +70,7 @@ public function __construct(array $definition, callable $provider) } $this->apiVersion = $this->getApiVersion(); if (isset($definition['clientContextParams'])) { - $this->clientContextParams = $definition['clientContextParams']; + $this->clientContextParams = $definition['clientContextParams']; } $this->protocol = $this->selectProtocol($definition); @@ -292,11 +292,6 @@ public function getOperation($name) $this->definition['operations'][$name], $this->shapeMap ); - } elseif ($this->modifiedModel) { - $this->operations[$name] = new Operation( - $this->definition['operations'][$name], - $this->shapeMap - ); } return $this->operations[$name]; @@ -526,9 +521,18 @@ public function setDefinition($definition) { $this->definition = $definition; $this->shapeMap = new ShapeMap($definition['shapes']); + $this->operations = []; $this->modifiedModel = true; } + /** + * @return void + */ + protected function clearResolvedModelCache() + { + $this->operations = []; + } + /** * Denotes whether or not a service's definition has * been modified. Intended for internal use only. @@ -555,7 +559,7 @@ private function selectProtocol(array $definition): string | null { $modeledProtocols = $definition['metadata']['protocols'] ?? null; if (!empty($modeledProtocols)) { - foreach(SupportedProtocols::cases() as $protocol) { + foreach (SupportedProtocols::cases() as $protocol) { if (in_array($protocol->value, $modeledProtocols)) { return $protocol->value; } diff --git a/src/Api/Shape.php b/src/Api/Shape.php index 765efc0359..9204630391 100644 --- a/src/Api/Shape.php +++ b/src/Api/Shape.php @@ -72,6 +72,8 @@ public function getName() */ public function getContextParam() { - return $this->contextParam; + // Read the live definition so direct mutation or removal of + // contextParam is reflected without a separate cached copy. + return $this->definition['contextParam'] ?? null; } } diff --git a/src/Api/ShapeMap.php b/src/Api/ShapeMap.php index 1593c2e321..5c4b10753d 100644 --- a/src/Api/ShapeMap.php +++ b/src/Api/ShapeMap.php @@ -12,6 +12,9 @@ class ShapeMap implements \ArrayAccess /** @var Shape[] */ private $simple; + /** @var int Monotonically increasing graph generation. */ + private $generation = 0; + /** * @param array $shapeModels Associative array of shape definitions. */ @@ -20,6 +23,32 @@ public function __construct(array $shapeModels) $this->definitions = $shapeModels; } + /** + * Get the current graph generation. + * + * Model objects record the generation their cached plans were built + * against. A mismatch signals that a related object mutated and the plans + * must be recompiled. + * + * @return int + * @internal + */ + public function getGeneration() + { + return $this->generation; + } + + /** + * Advance the graph generation after a model object mutates. + * + * @return void + * @internal + */ + public function incrementGeneration() + { + $this->generation++; + } + /** * Get an array of shape names. * diff --git a/src/Api/StructureShape.php b/src/Api/StructureShape.php index 2cfe7702e4..4e03d1c084 100644 --- a/src/Api/StructureShape.php +++ b/src/Api/StructureShape.php @@ -102,4 +102,12 @@ private function generateMembersHash() $this->members[$name] = $this->shapeFor($definition); } } + + /** + * @return void + */ + protected function clearResolvedModelCache() + { + $this->members = null; + } } diff --git a/tests/Api/OperationTest.php b/tests/Api/OperationTest.php index aa6e505c84..eac773d211 100644 --- a/tests/Api/OperationTest.php +++ b/tests/Api/OperationTest.php @@ -170,4 +170,116 @@ public function testGetOperationContextParams() $operationContextParams = $operation->getOperationContextParams(); $this->assertSame($definition['operationContextParams'], $operationContextParams); } + + public function testContextParamsFollowInputReplacement() + { + $o = new Operation( + ['input' => ['shape' => 'FooRequest']], + new ShapeMap([ + 'FooRequest' => [ + 'type' => 'structure', + 'members' => [ + 'Foo' => ['shape' => 'Str', 'contextParam' => ['name' => 'Foo']], + ], + ], + 'BarRequest' => [ + 'type' => 'structure', + 'members' => [ + 'Bar' => ['shape' => 'Str', 'contextParam' => ['name' => 'Bar']], + ], + ], + 'Str' => ['type' => 'string'], + ]) + ); + $this->assertSame(['Foo'], array_keys($o->getContextParams())); + + $o['input'] = ['shape' => 'BarRequest']; + + $this->assertSame(['Bar'], array_keys($o->getInput()->getMembers())); + $this->assertSame( + ['Bar' => ['shape' => 'Bar', 'type' => 'string']], + $o->getContextParams() + ); + } + + public function testContextParamsFollowInputShapeMutation() + { + $o = new Operation( + ['input' => ['shape' => 'FooRequest']], + new ShapeMap([ + 'FooRequest' => [ + 'type' => 'structure', + 'members' => [ + 'Foo' => ['shape' => 'Str', 'contextParam' => ['name' => 'Foo']], + ], + ], + 'Str' => ['type' => 'string'], + ]) + ); + $this->assertSame(['Foo'], array_keys($o->getContextParams())); + + $o->getInput()['members'] = [ + 'Bar' => ['shape' => 'Str', 'contextParam' => ['name' => 'Bar']], + ]; + + $this->assertSame(['Bar'], array_keys($o->getInput()->getMembers())); + $this->assertSame( + ['Bar' => ['shape' => 'Bar', 'type' => 'string']], + $o->getContextParams() + ); + } + + public function testContextParamsFollowMemberContextParamMutation() + { + $o = new Operation( + ['input' => ['shape' => 'FooRequest']], + new ShapeMap([ + 'FooRequest' => [ + 'type' => 'structure', + 'members' => [ + 'Foo' => ['shape' => 'Str', 'contextParam' => ['name' => 'Foo']], + ], + ], + 'Str' => ['type' => 'string'], + ]) + ); + $member = $o->getInput()->getMember('Foo'); + $this->assertSame(['Foo'], array_keys($o->getContextParams())); + + $member['contextParam'] = ['name' => 'Renamed']; + $this->assertSame(['name' => 'Renamed'], $member->getContextParam()); + $this->assertSame( + ['Renamed' => ['shape' => 'Foo', 'type' => 'string']], + $o->getContextParams() + ); + + unset($member['contextParam']); + $this->assertNull($member->getContextParam()); + $this->assertSame([], $o->getContextParams()); + } + + public function testStaticContextParamsFollowDefinitionChange() + { + $o = new Operation( + ['staticContextParams' => ['Foo' => ['value' => 'a']]], + new ShapeMap([]) + ); + + $o['staticContextParams'] = ['Foo' => ['value' => 'b']]; + $this->assertSame(['Foo' => ['value' => 'b']], $o->getStaticContextParams()); + + unset($o['staticContextParams']); + $this->assertSame([], $o->getStaticContextParams()); + } + + public function testOperationContextParamsFollowDefinitionChange() + { + $o = new Operation([], new ShapeMap([])); + $this->assertSame([], $o->getOperationContextParams()); + + $params = ['Keys' => ['path' => 'items[*].key']]; + $o['operationContextParams'] = $params; + + $this->assertSame($params, $o->getOperationContextParams()); + } } diff --git a/tests/Api/Parser/JsonParserTest.php b/tests/Api/Parser/JsonParserTest.php index b4128d3a9f..f94a54d816 100644 --- a/tests/Api/Parser/JsonParserTest.php +++ b/tests/Api/Parser/JsonParserTest.php @@ -4,6 +4,8 @@ use Aws\Api\Parser\Exception\ParserException; use Aws\Api\Parser\JsonRpcParser; use Aws\Api\Parser\JsonParser; +use Aws\Api\Shape; +use Aws\Api\ShapeMap; use Yoast\PHPUnitPolyfills\TestCases\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\CoversClass; @@ -134,4 +136,65 @@ public function testTimeStampExceptions( $this->expectException($expectedException, $expectedMessage); $handler($command)->wait(); } + + public static function sparseListProvider(): array + { + return [ + 'blob' => [['type' => 'blob'], [null, 'Zm9v'], [null, 'foo']], + 'timestamp' => [['type' => 'timestamp'], [null], [null]], + 'string' => [['type' => 'string'], ['a', null, 'b'], ['a', null, 'b']], + 'integer' => [['type' => 'integer'], [null, 1], [null, 1]], + 'boolean' => [['type' => 'boolean'], [null, false], [null, false]], + 'structure' => [ + ['type' => 'structure', 'members' => ['A' => ['type' => 'string']]], + [null, ['A' => 'x']], + [null, ['A' => 'x']], + ], + 'list' => [ + ['type' => 'list', 'member' => ['type' => 'blob']], + [null, [null, 'Zm9v']], + [null, [null, 'foo']], + ], + 'map' => [ + ['type' => 'map', 'key' => ['type' => 'string'], 'value' => ['type' => 'string']], + [null, ['k' => 'v']], + [null, ['k' => 'v']], + ], + ]; + } + + #[DataProvider('sparseListProvider')] + public function testNullListElementsStayNull( + array $memberDef, + array $input, + array $expected + ): void { + $shape = Shape::create( + ['type' => 'list', 'member' => $memberDef], + new ShapeMap([]) + ); + + $result = (new JsonParser())->parse($shape, $input); + + $this->assertSame($expected, $result); + } + + public function testNullListElementsStayNullInsideStructure(): void + { + $shape = Shape::create([ + 'type' => 'structure', + 'members' => [ + 'Blobs' => ['type' => 'list', 'member' => ['type' => 'blob']], + 'Times' => ['type' => 'list', 'member' => ['type' => 'timestamp']], + ], + ], new ShapeMap([])); + + $result = (new JsonParser())->parse($shape, [ + 'Blobs' => [null, 'Zm9v'], + 'Times' => [null], + ]); + + $this->assertSame([null, 'foo'], $result['Blobs']); + $this->assertSame([null], $result['Times']); + } } diff --git a/tests/Api/Serde/Json/JsonDecodePlanProviderTest.php b/tests/Api/Serde/Json/JsonDecodePlanProviderTest.php new file mode 100644 index 0000000000..a2b7fa9557 --- /dev/null +++ b/tests/Api/Serde/Json/JsonDecodePlanProviderTest.php @@ -0,0 +1,145 @@ +shape([ + 'type' => 'structure', + 'members' => [ + 'First' => ['type' => 'string', 'locationName' => 'first_wire'], + 'Second' => ['type' => 'timestamp'], + 'Third' => ['type' => 'map', 'key' => ['type' => 'string'], 'value' => ['type' => 'string']], + ], + ]); + + $plan = $provider->get($shape); + + $this->assertSame(JsonShapeType::STRUCTURE, $plan->type); + // Ordered list, not name-keyed: preserves modeled result order. + $this->assertSame('First', $plan->members[0][JsonDecodePlan::M_SDK]); + $this->assertSame('Second', $plan->members[1][JsonDecodePlan::M_SDK]); + $this->assertSame('Third', $plan->members[2][JsonDecodePlan::M_SDK]); + + // Wire name from locationName; SDK name otherwise. + $this->assertSame('first_wire', $plan->members[0][JsonDecodePlan::M_WIRE]); + $this->assertSame('Second', $plan->members[1][JsonDecodePlan::M_WIRE]); + + // Type tags. + $this->assertSame(JsonShapeType::SCALAR, $plan->members[0][JsonDecodePlan::M_TYPE]); + $this->assertSame(JsonShapeType::TIMESTAMP, $plan->members[1][JsonDecodePlan::M_TYPE]); + $this->assertSame(JsonShapeType::MAP, $plan->members[2][JsonDecodePlan::M_TYPE]); + } + + public function testDecodeTimestampDefaultsToNull(): void + { + // JsonParser defaults timestamp decode format to null (DateTimeResult), + // unlike encode which defaults to unixTimestamp. + $provider = new JsonDecodePlanProvider(); + $shape = $this->shape([ + 'type' => 'structure', + 'members' => ['When' => ['type' => 'timestamp']], + ]); + + $plan = $provider->get($shape); + + $this->assertNull($plan->members[0][JsonDecodePlan::M_TSFORMAT]); + } + + public function testDecodeTimestampKeepsExplicitFormat(): void + { + $provider = new JsonDecodePlanProvider(); + $shape = $this->shape([ + 'type' => 'structure', + 'members' => ['When' => ['type' => 'timestamp', 'timestampFormat' => 'iso8601']], + ]); + + $plan = $provider->get($shape); + + $this->assertSame('iso8601', $plan->members[0][JsonDecodePlan::M_TSFORMAT]); + } + + public function testCompilesRootTimestamp(): void + { + $provider = new JsonDecodePlanProvider(); + + // No explicit format: decode defaults to null (DateTimeResult). + $plain = $provider->get($this->shape(['type' => 'timestamp'])); + $this->assertSame(JsonShapeType::TIMESTAMP, $plain->type); + $this->assertNull($plain->timestampFormat); + + // Explicit format is retained on the root plan. + $iso = $provider->get($this->shape(['type' => 'timestamp', 'timestampFormat' => 'iso8601'])); + $this->assertSame('iso8601', $iso->timestampFormat); + } + + public function testUnionFlagSet(): void + { + $provider = new JsonDecodePlanProvider(); + $union = $this->shape([ + 'type' => 'structure', + 'union' => true, + 'members' => ['A' => ['type' => 'string']], + ]); + $nonUnion = $this->shape([ + 'type' => 'structure', + 'members' => ['A' => ['type' => 'string']], + ]); + + $this->assertTrue($provider->get($union)->union); + $this->assertFalse($provider->get($nonUnion)->union); + } + + public function testCompilesListAndMapValueDescriptors(): void + { + $provider = new JsonDecodePlanProvider(); + + $list = $provider->get($this->shape([ + 'type' => 'list', + 'member' => ['type' => 'string'], + ])); + $this->assertSame(JsonShapeType::LIST, $list->type); + $this->assertSame(JsonShapeType::SCALAR, $list->value[JsonDecodePlan::V_TYPE]); + + $map = $provider->get($this->shape([ + 'type' => 'map', + 'key' => ['type' => 'string'], + 'value' => ['type' => 'timestamp'], + ])); + $this->assertSame(JsonShapeType::MAP, $map->type); + $this->assertSame(JsonShapeType::TIMESTAMP, $map->value[JsonDecodePlan::V_TYPE]); + $this->assertNull($map->value[JsonDecodePlan::V_TSFORMAT]); + } + + public function testCachesPlanPerShapeInDecodeSlot(): void + { + $provider = new JsonDecodePlanProvider(); + $shape = $this->shape(['type' => 'structure', 'members' => []]); + + $first = $provider->get($shape); + $second = $provider->get($shape); + + $this->assertSame($first, $second); + $this->assertSame($first, $shape->getSerdePlan(ShapePlanCache::JSON_DECODE)); + // Decode plan does not occupy the encode slot. + $this->assertNull($shape->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + } +} diff --git a/tests/Api/Serde/Json/JsonEncodePlanProviderTest.php b/tests/Api/Serde/Json/JsonEncodePlanProviderTest.php new file mode 100644 index 0000000000..8e3234c6e6 --- /dev/null +++ b/tests/Api/Serde/Json/JsonEncodePlanProviderTest.php @@ -0,0 +1,130 @@ +shape([ + 'type' => 'structure', + 'members' => [ + 'Name' => ['type' => 'string', 'locationName' => 'name'], + 'Count' => ['type' => 'integer'], + 'When' => ['type' => 'timestamp'], + 'Body' => ['type' => 'blob'], + 'Items' => ['type' => 'list', 'member' => ['type' => 'string']], + ], + ]); + + $plan = $provider->get($shape); + + $this->assertSame(JsonShapeType::STRUCTURE, $plan->type); + + // Wire name uses locationName when present, SDK name otherwise. + $this->assertSame('name', $plan->members['Name'][JsonEncodePlan::M_WIRE]); + $this->assertSame('Count', $plan->members['Count'][JsonEncodePlan::M_WIRE]); + + // Type tags per member. + $this->assertSame(JsonShapeType::SCALAR, $plan->members['Name'][JsonEncodePlan::M_TYPE]); + $this->assertSame(JsonShapeType::SCALAR, $plan->members['Count'][JsonEncodePlan::M_TYPE]); + $this->assertSame(JsonShapeType::TIMESTAMP, $plan->members['When'][JsonEncodePlan::M_TYPE]); + $this->assertSame(JsonShapeType::BLOB, $plan->members['Body'][JsonEncodePlan::M_TYPE]); + $this->assertSame(JsonShapeType::LIST, $plan->members['Items'][JsonEncodePlan::M_TYPE]); + + // Timestamp member defaults to unixTimestamp on encode; non-timestamps null. + $this->assertSame('unixTimestamp', $plan->members['When'][JsonEncodePlan::M_TSFORMAT]); + $this->assertNull($plan->members['Name'][JsonEncodePlan::M_TSFORMAT]); + + // Child Shape is retained for lazy composite lookup. + $this->assertInstanceOf(Shape::class, $plan->members['Items'][JsonEncodePlan::M_SHAPE]); + } + + public function testCompilesListValueDescriptor(): void + { + $provider = new JsonEncodePlanProvider(); + $shape = $this->shape([ + 'type' => 'list', + 'member' => ['type' => 'timestamp'], + ]); + + $plan = $provider->get($shape); + + $this->assertSame(JsonShapeType::LIST, $plan->type); + $this->assertSame(JsonShapeType::TIMESTAMP, $plan->value[JsonEncodePlan::V_TYPE]); + $this->assertSame('unixTimestamp', $plan->value[JsonEncodePlan::V_TSFORMAT]); + $this->assertInstanceOf(Shape::class, $plan->value[JsonEncodePlan::V_SHAPE]); + } + + public function testCompilesMapValueDescriptor(): void + { + $provider = new JsonEncodePlanProvider(); + $shape = $this->shape([ + 'type' => 'map', + 'key' => ['type' => 'string'], + 'value' => ['type' => 'string'], + ]); + + $plan = $provider->get($shape); + + $this->assertSame(JsonShapeType::MAP, $plan->type); + $this->assertSame(JsonShapeType::SCALAR, $plan->value[JsonEncodePlan::V_TYPE]); + $this->assertNull($plan->value[JsonEncodePlan::V_TSFORMAT]); + } + + public function testCompilesRootTimestampWithExplicitFormat(): void + { + $provider = new JsonEncodePlanProvider(); + $shape = $this->shape(['type' => 'timestamp', 'timestampFormat' => 'iso8601']); + + $plan = $provider->get($shape); + + $this->assertSame(JsonShapeType::TIMESTAMP, $plan->type); + $this->assertSame('iso8601', $plan->timestampFormat); + } + + public function testDocumentStructureTagsAsDocument(): void + { + $provider = new JsonEncodePlanProvider(); + $shape = $this->shape([ + 'type' => 'structure', + 'document' => true, + 'members' => [], + ]); + + $plan = $provider->get($shape); + + $this->assertSame(JsonShapeType::DOCUMENT, $plan->type); + } + + public function testCachesPlanPerShapeInEncodeSlot(): void + { + $provider = new JsonEncodePlanProvider(); + $shape = $this->shape(['type' => 'structure', 'members' => []]); + + $first = $provider->get($shape); + $second = $provider->get($shape); + + // Same instance returned: compiled once and cached. + $this->assertSame($first, $second); + // Stored under the JSON_ENCODE slot. + $this->assertSame($first, $shape->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + } +} diff --git a/tests/Api/Serde/Json/JsonPlanInvalidationTest.php b/tests/Api/Serde/Json/JsonPlanInvalidationTest.php new file mode 100644 index 0000000000..2444e7825d --- /dev/null +++ b/tests/Api/Serde/Json/JsonPlanInvalidationTest.php @@ -0,0 +1,208 @@ + 'structure', 'members' => $members], + $map + ); + } + + public function testLocationNameMutationRebuildsEncodePlan(): void + { + $provider = new JsonEncodePlanProvider(); + $map = new ShapeMap([]); + $shape = $this->structure( + ['Name' => ['type' => 'string', 'locationName' => 'old']], + $map + ); + + $plan1 = $provider->get($shape); + $this->assertSame('old', $plan1->members['Name'][JsonEncodePlan::M_WIRE]); + + // Mutate the definition: this bumps the graph generation. + $shape['members'] = ['Name' => ['type' => 'string', 'locationName' => 'new']]; + + $plan2 = $provider->get($shape); + $this->assertNotSame($plan1, $plan2, 'Plan should rebuild after mutation'); + $this->assertSame('new', $plan2->members['Name'][JsonEncodePlan::M_WIRE]); + } + + public function testLocationNameMutationRebuildsDecodePlan(): void + { + $provider = new JsonDecodePlanProvider(); + $map = new ShapeMap([]); + $shape = $this->structure( + ['Name' => ['type' => 'string', 'locationName' => 'old']], + $map + ); + + $plan1 = $provider->get($shape); + $this->assertSame('old', $plan1->members[0][JsonDecodePlan::M_WIRE]); + + $shape['members'] = ['Name' => ['type' => 'string', 'locationName' => 'new']]; + + $plan2 = $provider->get($shape); + $this->assertNotSame($plan1, $plan2); + $this->assertSame('new', $plan2->members[0][JsonDecodePlan::M_WIRE]); + } + + public function testMembersReplacementRebuildsPlan(): void + { + $provider = new JsonEncodePlanProvider(); + $map = new ShapeMap([]); + $shape = $this->structure(['A' => ['type' => 'string']], $map); + + $plan1 = $provider->get($shape); + $this->assertArrayHasKey('A', $plan1->members); + $this->assertArrayNotHasKey('B', $plan1->members); + + // Replace the members definition entirely. + $shape['members'] = ['B' => ['type' => 'integer']]; + + $plan2 = $provider->get($shape); + $this->assertNotSame($plan1, $plan2); + $this->assertArrayHasKey('B', $plan2->members); + $this->assertArrayNotHasKey('A', $plan2->members); + } + + public function testGenerationBumpInvalidatesBothDirections(): void + { + $encode = new JsonEncodePlanProvider(); + $decode = new JsonDecodePlanProvider(); + $map = new ShapeMap([]); + $shape = $this->structure( + ['Name' => ['type' => 'string', 'locationName' => 'old']], + $map + ); + + $enc1 = $encode->get($shape); + $dec1 = $decode->get($shape); + + $shape['members'] = ['Name' => ['type' => 'string', 'locationName' => 'new']]; + + $this->assertNotSame($enc1, $encode->get($shape)); + $this->assertNotSame($dec1, $decode->get($shape)); + } + + public function testStableGenerationReusesCachedPlan(): void + { + $provider = new JsonEncodePlanProvider(); + $map = new ShapeMap([]); + $shape = $this->structure(['A' => ['type' => 'string']], $map); + + // No mutation between gets: same cached plan instance. + $this->assertSame($provider->get($shape), $provider->get($shape)); + } + + public function testOffsetUnsetInvalidatesPlan(): void + { + // The invalidation contract covers offsetUnset as well as offsetSet. + // Unset an optional shape-level trait and confirm the plan rebuilds. + $provider = new JsonEncodePlanProvider(); + $map = new ShapeMap([]); + $shape = $this->structure(['A' => ['type' => 'string']], $map); + $shape['union'] = true; + + $plan1 = $provider->get($shape); + + // Removing a trait via unset() bumps the graph generation. + unset($shape['union']); + + $plan2 = $provider->get($shape); + $this->assertNotSame($plan1, $plan2, 'offsetUnset should invalidate the plan'); + $this->assertArrayHasKey('A', $plan2->members); + } + + public function testListShapeClearsResolvedMemberOnMutation(): void + { + // A ListShape memoizes its resolved member; mutation must drop it so the + // rebuilt plan reflects the new member definition. + $map = new ShapeMap([]); + $list = new ListShape( + ['type' => 'list', 'member' => ['type' => 'string']], + $map + ); + $provider = new JsonEncodePlanProvider(); + + $plan1 = $provider->get($list); + $memberBefore = $list->getMember(); + + $list['member'] = ['type' => 'integer']; + + $memberAfter = $list->getMember(); + $this->assertNotSame( + $memberBefore, + $memberAfter, + 'Resolved member should be re-resolved after mutation' + ); + $plan2 = $provider->get($list); + $this->assertNotSame($plan1, $plan2); + } + + public function testMapShapeClearsResolvedValueOnMutation(): void + { + $map = new ShapeMap([]); + $mapShape = new MapShape( + [ + 'type' => 'map', + 'key' => ['type' => 'string'], + 'value' => ['type' => 'string'], + ], + $map + ); + $provider = new JsonEncodePlanProvider(); + + $plan1 = $provider->get($mapShape); + $valueBefore = $mapShape->getValue(); + + $mapShape['value'] = ['type' => 'integer']; + + $valueAfter = $mapShape->getValue(); + $this->assertNotSame( + $valueBefore, + $valueAfter, + 'Resolved value should be re-resolved after mutation' + ); + $plan2 = $provider->get($mapShape); + $this->assertNotSame($plan1, $plan2); + } + + public function testMockWithoutShapeMapIsSafe(): void + { + // Mocks may construct shapes without a live ShapeMap. Plan access and + // mutation must not error in that state. + $provider = new JsonEncodePlanProvider(); + $shape = Shape::create( + ['type' => 'structure', 'members' => ['A' => ['type' => 'string']]], + new ShapeMap([]) + ); + + $plan = $provider->get($shape); + $this->assertArrayHasKey('A', $plan->members); + + // Mutating still resolves without throwing. + $shape['members'] = ['B' => ['type' => 'string']]; + $plan2 = $provider->get($shape); + $this->assertArrayHasKey('B', $plan2->members); + } +} diff --git a/tests/Api/Serde/ShapePlanCacheTest.php b/tests/Api/Serde/ShapePlanCacheTest.php new file mode 100644 index 0000000000..3c1b04305a --- /dev/null +++ b/tests/Api/Serde/ShapePlanCacheTest.php @@ -0,0 +1,244 @@ +assertCount(count($slots), array_unique($slots)); + } + + public function testCachesAndReturnsPlan() + { + $shape = new Shape(['type' => 'string', 'name' => 'S'], new ShapeMap([])); + $plan = new \stdClass(); + + $this->assertNull($shape->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + $this->assertSame( + $plan, + $shape->cacheSerdePlan(ShapePlanCache::JSON_ENCODE, $plan) + ); + $this->assertSame( + $plan, + $shape->getSerdePlan(ShapePlanCache::JSON_ENCODE) + ); + } + + public function testDirectionSpecificSlotsAreIndependent() + { + $shape = new Shape(['type' => 'string', 'name' => 'S'], new ShapeMap([])); + $encode = new \stdClass(); + $decode = new \stdClass(); + + $shape->cacheSerdePlan(ShapePlanCache::JSON_ENCODE, $encode); + $shape->cacheSerdePlan(ShapePlanCache::JSON_DECODE, $decode); + + $this->assertSame($encode, $shape->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + $this->assertSame($decode, $shape->getSerdePlan(ShapePlanCache::JSON_DECODE)); + } + + public function testMutatingOneShapeInvalidatesPlansOnRelatedShapes() + { + $shapeMap = new ShapeMap([ + 'A' => ['type' => 'string'], + 'B' => ['type' => 'string'], + ]); + $a = $shapeMap->resolve(['shape' => 'A']); + $b = $shapeMap->resolve(['shape' => 'B']); + $plan = new \stdClass(); + + $a->cacheSerdePlan(ShapePlanCache::JSON_ENCODE, $plan); + $this->assertSame($plan, $a->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + + // Mutating B advances the graph generation, invalidating A's plan. + $b['documentation'] = 'changed'; + + $this->assertNull($a->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + } + + public function testMutatedShapeCanCacheAgainstNewGeneration() + { + $shape = new Shape(['type' => 'string', 'name' => 'S'], new ShapeMap([])); + $shape->cacheSerdePlan(ShapePlanCache::JSON_ENCODE, new \stdClass()); + + $shape['documentation'] = 'changed'; + $this->assertNull($shape->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + + $rebuilt = new \stdClass(); + $shape->cacheSerdePlan(ShapePlanCache::JSON_ENCODE, $rebuilt); + $this->assertSame($rebuilt, $shape->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + } + + public function testMutatingChildMemberLocationNameClearsResolvedMembersAndPlans() + { + $struct = $this->structShape(); + $struct->cacheSerdePlan(ShapePlanCache::JSON_ENCODE, new \stdClass()); + + $member = $struct->getMember('A'); + $member['locationName'] = 'renamed'; + + // The parent's plan is stale after a related-shape mutation. + $this->assertNull($struct->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + } + + public function testReplacingStructureMembersRebuildsResolvedMembers() + { + $struct = $this->structShape(); + $this->assertSame(['A'], array_keys($struct->getMembers())); + + $struct['members'] = [ + 'A' => ['shape' => 'Str'], + 'B' => ['shape' => 'Str'], + ]; + + $this->assertSame(['A', 'B'], array_keys($struct->getMembers())); + } + + public function testListMemberResolutionClearedOnMutation() + { + $shapeMap = new ShapeMap([ + 'List' => ['type' => 'list', 'member' => ['shape' => 'Str']], + 'Str' => ['type' => 'string'], + 'Int' => ['type' => 'integer'], + ]); + /** @var ListShape $list */ + $list = $shapeMap->resolve(['shape' => 'List']); + $this->assertSame('string', $list->getMember()->getType()); + + $list['member'] = ['shape' => 'Int']; + $this->assertSame('integer', $list->getMember()->getType()); + } + + public function testMapValueResolutionClearedOnMutation() + { + $shapeMap = new ShapeMap([ + 'Map' => [ + 'type' => 'map', + 'key' => ['shape' => 'Str'], + 'value' => ['shape' => 'Str'], + ], + 'Str' => ['type' => 'string'], + 'Int' => ['type' => 'integer'], + ]); + /** @var MapShape $map */ + $map = $shapeMap->resolve(['shape' => 'Map']); + $this->assertSame('string', $map->getValue()->getType()); + + $map['value'] = ['shape' => 'Int']; + $this->assertSame('integer', $map->getValue()->getType()); + } + + public function testOperationOwnsHttpBindingPlans() + { + $service = $this->service(); + $operation = $service->getOperation('Foo'); + $plan = new \stdClass(); + + $operation->cacheSerdePlan(ShapePlanCache::HTTP_REQUEST_BINDINGS, $plan); + $this->assertSame( + $plan, + $operation->getSerdePlan(ShapePlanCache::HTTP_REQUEST_BINDINGS) + ); + } + + public function testSetDefinitionReplacesOperationsButKeepsThemStable() + { + $service = $this->service(); + + $first = $service->getOperation('Foo'); + $this->assertSame($first, $service->getOperation('Foo'), 'operations cache and stay stable'); + + $definition = $service->getDefinition(); + $definition['metadata']['serviceId'] = 'changed'; + $service->setDefinition($definition); + + $replaced = $service->getOperation('Foo'); + $this->assertNotSame($first, $replaced, 'setDefinition replaces stale operations'); + $this->assertSame($replaced, $service->getOperation('Foo'), 'new operation stays cached'); + } + + public function testCacheIsSafeWithoutShapeMap() + { + // Mocks may construct model objects without a ShapeMap. + $shape = (new \ReflectionClass(Shape::class))->newInstanceWithoutConstructor(); + $plan = new \stdClass(); + + $this->assertNull($shape->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + $shape->cacheSerdePlan(ShapePlanCache::JSON_ENCODE, $plan); + $this->assertSame($plan, $shape->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + + // Mutation must not fault when no ShapeMap is present. + $shape['documentation'] = 'changed'; + $this->assertNull($shape->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + } + + private function structShape(): StructureShape + { + $shapeMap = new ShapeMap([ + 'Struct' => [ + 'type' => 'structure', + 'members' => ['A' => ['shape' => 'Str']], + ], + 'Str' => ['type' => 'string'], + ]); + + return $shapeMap->resolve(['shape' => 'Struct']); + } + + private function service(): Service + { + return new Service( + [ + 'metadata' => [ + 'serviceIdentifier' => 'foo', + 'endpointPrefix' => 'foo', + 'apiVersion' => '2020-01-01', + 'protocol' => 'json', + ], + 'operations' => [ + 'Foo' => [ + 'name' => 'Foo', + 'http' => ['method' => 'POST', 'requestUri' => '/'], + 'input' => ['shape' => 'FooInput'], + 'output' => ['shape' => 'FooOutput'], + ], + ], + 'shapes' => [ + 'FooInput' => ['type' => 'structure', 'members' => []], + 'FooOutput' => ['type' => 'structure', 'members' => []], + ], + ], + function () { + return []; + } + ); + } +} diff --git a/tests/Api/Serde/Xml/XmlDecodePlanProviderTest.php b/tests/Api/Serde/Xml/XmlDecodePlanProviderTest.php new file mode 100644 index 0000000000..eaf398df43 --- /dev/null +++ b/tests/Api/Serde/Xml/XmlDecodePlanProviderTest.php @@ -0,0 +1,214 @@ +shape([ + 'type' => 'structure', + 'members' => [ + 'First' => ['type' => 'string', 'locationName' => 'first_wire'], + 'Second' => ['type' => 'timestamp'], + 'Items' => ['type' => 'list', 'member' => ['type' => 'string']], + ], + ]); + + $plan = $provider->get($shape); + + $this->assertSame(XmlShapeType::STRUCTURE, $plan->type); + // Modeled order preserved. + $this->assertSame('First', $plan->members[0][XmlDecodePlan::M_SDK]); + $this->assertSame('Second', $plan->members[1][XmlDecodePlan::M_SDK]); + $this->assertSame('Items', $plan->members[2][XmlDecodePlan::M_SDK]); + // Node name from locationName, else SDK name. + $this->assertSame('first_wire', $plan->members[0][XmlDecodePlan::M_NODE]); + $this->assertSame('Second', $plan->members[1][XmlDecodePlan::M_NODE]); + // Type tags. + $this->assertSame(XmlShapeType::TIMESTAMP, $plan->members[1][XmlDecodePlan::M_TYPE]); + $this->assertSame(XmlShapeType::LIST, $plan->members[2][XmlDecodePlan::M_TYPE]); + } + + public function testAttributeMemberKeyStripsNamespacePrefix(): void + { + $provider = new XmlDecodePlanProvider(); + $shape = $this->shape([ + 'type' => 'structure', + 'xmlNamespace' => ['prefix' => 'ns2', 'uri' => 'http://ns.example'], + 'members' => [ + 'Id' => ['type' => 'string', 'xmlAttribute' => true, 'locationName' => 'ns2:id'], + ], + ]); + + $m = $provider->get($shape)->members[0]; + $this->assertTrue($m[XmlDecodePlan::M_ATTRIBUTE]); + // Prefix stripped from the attribute key; namespace uri retained. + $this->assertSame('id', $m[XmlDecodePlan::M_ATTRKEY]); + $this->assertSame('http://ns.example', $m[XmlDecodePlan::M_ATTRNS]); + } + + public function testDecodeTimestampDefaultsToNull(): void + { + // XML decode default is null (DateTimeResult auto-detect), unlike the + // encode default of iso8601. + $provider = new XmlDecodePlanProvider(); + $shape = $this->shape([ + 'type' => 'structure', + 'members' => ['When' => ['type' => 'timestamp']], + ]); + $this->assertNull($provider->get($shape)->members[0][XmlDecodePlan::M_TSFORMAT]); + + $explicit = $this->shape([ + 'type' => 'structure', + 'members' => ['When' => ['type' => 'timestamp', 'timestampFormat' => 'rfc822']], + ]); + $this->assertSame('rfc822', $provider->get($explicit)->members[0][XmlDecodePlan::M_TSFORMAT]); + } + + public function testUnionFlag(): void + { + $provider = new XmlDecodePlanProvider(); + $union = $this->shape([ + 'type' => 'structure', 'union' => true, + 'members' => ['A' => ['type' => 'string']], + ]); + $nonUnion = $this->shape([ + 'type' => 'structure', + 'members' => ['A' => ['type' => 'string']], + ]); + $this->assertTrue($provider->get($union)->union); + $this->assertFalse($provider->get($nonUnion)->union); + } + + public function testListFlattenedAndItemName(): void + { + $provider = new XmlDecodePlanProvider(); + $flat = $provider->get($this->shape([ + 'type' => 'list', 'flattened' => true, + 'member' => ['type' => 'string'], + ])); + $this->assertTrue($flat->flattened); + + $wrapped = $provider->get($this->shape([ + 'type' => 'list', + 'member' => ['type' => 'string', 'locationName' => 'Item'], + ])); + $this->assertFalse($wrapped->flattened); + $this->assertSame('Item', $wrapped->listItemName); + $this->assertSame(XmlShapeType::SCALAR, $wrapped->listItemType); + + $default = $provider->get($this->shape([ + 'type' => 'list', 'member' => ['type' => 'string'], + ])); + $this->assertSame('member', $default->listItemName); + } + + public function testMapNamesAndFlattening(): void + { + $provider = new XmlDecodePlanProvider(); + $wrapped = $provider->get($this->shape([ + 'type' => 'map', + 'key' => ['type' => 'string'], + 'value' => ['type' => 'timestamp'], + ])); + $this->assertSame(XmlShapeType::MAP, $wrapped->type); + $this->assertFalse($wrapped->flattened); + $this->assertSame('key', $wrapped->mapKeyName); + $this->assertSame('value', $wrapped->mapValueName); + $this->assertSame(XmlShapeType::TIMESTAMP, $wrapped->mapValueType); + // Decode timestamp default null carried on the map value. + $this->assertNull($wrapped->mapValueTsFormat); + + $custom = $provider->get($this->shape([ + 'type' => 'map', 'flattened' => true, + 'key' => ['type' => 'string', 'locationName' => 'K'], + 'value' => ['type' => 'string', 'locationName' => 'V'], + ])); + $this->assertTrue($custom->flattened); + $this->assertSame('K', $custom->mapKeyName); + $this->assertSame('V', $custom->mapValueName); + } + + public function testRootTimestampAndLeafTags(): void + { + $provider = new XmlDecodePlanProvider(); + $ts = $provider->get($this->shape(['type' => 'timestamp'])); + $this->assertSame(XmlShapeType::TIMESTAMP, $ts->type); + $this->assertNull($ts->timestampFormat); + + // Leaf tags via a structure to cover blob/boolean branches. + $plan = $provider->get($this->shape([ + 'type' => 'structure', + 'members' => [ + 'Body' => ['type' => 'blob'], + 'Flag' => ['type' => 'boolean'], + 'N' => ['type' => 'integer'], + ], + ])); + $this->assertSame(XmlShapeType::BLOB, $plan->members[0][XmlDecodePlan::M_TYPE]); + $this->assertSame(XmlShapeType::BOOLEAN, $plan->members[1][XmlDecodePlan::M_TYPE]); + $this->assertSame(XmlShapeType::SCALAR, $plan->members[2][XmlDecodePlan::M_TYPE]); + } + + public function testStructureMemberInheritedLocationNameReadsByMemberName(): void + { + // Reproduces XmlParser::memberKey's getOriginalDefinition special case: + // a StructureShape member whose locationName was inherited from the + // target shape definition (structure level) reads by the member name, + // not the locationName. Built via a ShapeMap so the named target shape + // carries a structure-level locationName that ShapeMap::resolve merges + // onto the member reference. + $map = new ShapeMap([ + 'Outer' => [ + 'type' => 'structure', + 'members' => [ + 'Inner' => ['shape' => 'Inner'], + ], + ], + 'Inner' => [ + 'type' => 'structure', + 'locationName' => 'InheritedName', + 'members' => [ + 'Field' => ['type' => 'string'], + ], + ], + ]); + $outer = $map->resolve(['shape' => 'Outer']); + + $plan = (new XmlDecodePlanProvider())->get($outer); + + // The Inner member inherited locationName 'InheritedName' from the shape + // definition, so it must be read by the member name 'Inner'. + $this->assertSame('Inner', $plan->members[0][XmlDecodePlan::M_SDK]); + $this->assertSame('Inner', $plan->members[0][XmlDecodePlan::M_NODE]); + } + + public function testCachesPlanPerShapeInXmlDecodeSlot(): void + { + $provider = new XmlDecodePlanProvider(); + $shape = $this->shape(['type' => 'structure', 'members' => []]); + + $first = $provider->get($shape); + $this->assertSame($first, $provider->get($shape)); + $this->assertSame($first, $shape->getSerdePlan(ShapePlanCache::XML_DECODE)); + // Does not collide with the XML encode slot. + $this->assertNull($shape->getSerdePlan(ShapePlanCache::XML_ENCODE)); + } +} diff --git a/tests/Api/Serde/Xml/XmlEncodePlanProviderTest.php b/tests/Api/Serde/Xml/XmlEncodePlanProviderTest.php new file mode 100644 index 0000000000..9893799789 --- /dev/null +++ b/tests/Api/Serde/Xml/XmlEncodePlanProviderTest.php @@ -0,0 +1,250 @@ +shape([ + 'type' => 'structure', + 'xmlNamespace' => ['uri' => 'http://ns.example'], + 'members' => [ + 'Id' => ['type' => 'string', 'xmlAttribute' => true, 'locationName' => 'id'], + 'Name' => ['type' => 'string', 'locationName' => 'thing_name'], + 'When' => ['type' => 'timestamp'], + ], + ]); + + $plan = $provider->get($shape); + + $this->assertSame(XmlShapeType::STRUCTURE, $plan->type); + // Namespace precomputed as [attrName, uri]. + $this->assertSame(['xmlns', 'http://ns.example'], $plan->namespace); + + // Member element names resolved from locationName. + $this->assertSame('id', $plan->members['Id'][XmlEncodePlan::M_ELEMENT]); + $this->assertSame('thing_name', $plan->members['Name'][XmlEncodePlan::M_ELEMENT]); + + // Attribute flag + attributeMembers list. + $this->assertTrue($plan->members['Id'][XmlEncodePlan::M_ATTRIBUTE]); + $this->assertFalse($plan->members['Name'][XmlEncodePlan::M_ATTRIBUTE]); + $this->assertSame(['Id'], $plan->attributeMembers); + + // Type tags. + $this->assertSame(XmlShapeType::SCALAR, $plan->members['Name'][XmlEncodePlan::M_TYPE]); + $this->assertSame(XmlShapeType::TIMESTAMP, $plan->members['When'][XmlEncodePlan::M_TYPE]); + } + + public function testOnlyStringAttributesGetAnAttributeName(): void + { + $provider = new XmlEncodePlanProvider(); + $shape = $this->shape([ + 'type' => 'structure', + 'members' => [ + 'Id' => ['type' => 'string', 'xmlAttribute' => true, 'locationName' => 'id'], + 'Count' => ['type' => 'integer', 'xmlAttribute' => true], + 'Name' => ['type' => 'string'], + ], + ]); + + $plan = $provider->get($shape); + + $this->assertSame('id', $plan->members['Id'][XmlEncodePlan::M_ATTR_NAME]); + // Non-string xmlAttribute members are still ordered first but written + // as elements, so they carry no attribute name. + $this->assertTrue($plan->members['Count'][XmlEncodePlan::M_ATTRIBUTE]); + $this->assertNull($plan->members['Count'][XmlEncodePlan::M_ATTR_NAME]); + $this->assertNull($plan->members['Name'][XmlEncodePlan::M_ATTR_NAME]); + $this->assertSame(['Id', 'Count'], $plan->attributeMembers); + } + + public function testPrefixedNamespace(): void + { + $provider = new XmlEncodePlanProvider(); + $shape = $this->shape([ + 'type' => 'structure', + 'xmlNamespace' => ['prefix' => 'ns2', 'uri' => 'http://ns.example'], + 'members' => [], + ]); + + $this->assertSame( + ['xmlns:ns2', 'http://ns.example'], + $provider->get($shape)->namespace + ); + } + + public function testLocationNameAtStructureLevelIsIgnored(): void + { + // When locationName came from the structure level, the member element + // name falls back to the SDK member name (matches XmlBody). + $provider = new XmlEncodePlanProvider(); + $shape = $this->shape([ + 'type' => 'structure', + 'members' => [ + 'Name' => [ + 'type' => 'string', + 'locationName' => 'should_be_ignored', + 'locationNameAtStructureLevel' => true, + ], + ], + ]); + + $this->assertSame('Name', $provider->get($shape)->members['Name'][XmlEncodePlan::M_ELEMENT]); + } + + public function testFlattenedList(): void + { + $provider = new XmlEncodePlanProvider(); + $flat = $provider->get($this->shape([ + 'type' => 'list', + 'flattened' => true, + 'member' => ['type' => 'string'], + ])); + $this->assertSame(XmlShapeType::LIST, $flat->type); + $this->assertTrue($flat->flattened); + + $wrapped = $provider->get($this->shape([ + 'type' => 'list', + 'member' => ['type' => 'string', 'locationName' => 'Item'], + ])); + $this->assertFalse($wrapped->flattened); + $this->assertSame('Item', $wrapped->listItemName); + } + + public function testWrappedListDefaultsItemNameToMember(): void + { + $provider = new XmlEncodePlanProvider(); + $plan = $provider->get($this->shape([ + 'type' => 'list', + 'member' => ['type' => 'string'], + ])); + $this->assertSame('member', $plan->listItemName); + } + + public function testMapNamesAndFlattening(): void + { + $provider = new XmlEncodePlanProvider(); + + $wrapped = $provider->get($this->shape([ + 'type' => 'map', + 'key' => ['type' => 'string'], + 'value' => ['type' => 'string'], + ])); + $this->assertSame(XmlShapeType::MAP, $wrapped->type); + $this->assertFalse($wrapped->flattened); + $this->assertSame('entry', $wrapped->mapEntryName); + $this->assertSame('key', $wrapped->mapKeyName); + $this->assertSame('value', $wrapped->mapValueName); + + $custom = $provider->get($this->shape([ + 'type' => 'map', + 'flattened' => true, + 'key' => ['type' => 'string', 'locationName' => 'K'], + 'value' => ['type' => 'string', 'locationName' => 'V'], + ])); + $this->assertTrue($custom->flattened); + $this->assertSame('K', $custom->mapKeyName); + $this->assertSame('V', $custom->mapValueName); + } + + public function testTimestampDefaultsToIso8601(): void + { + // XML default is iso8601, unlike JSON's unixTimestamp. + $provider = new XmlEncodePlanProvider(); + $plan = $provider->get($this->shape([ + 'type' => 'structure', + 'members' => ['When' => ['type' => 'timestamp']], + ])); + $this->assertSame( + 'iso8601', + $plan->members['When'][XmlEncodePlan::M_TSFORMAT] + ); + + $list = $provider->get($this->shape([ + 'type' => 'list', + 'member' => [ + 'type' => 'timestamp', + 'timestampFormat' => 'unixTimestamp', + ], + ])); + $this->assertSame( + 'unixTimestamp', + $list->listItemTimestampFormat + ); + + $map = $provider->get($this->shape([ + 'type' => 'map', + 'key' => ['type' => 'string'], + 'value' => [ + 'type' => 'timestamp', + 'timestampFormat' => 'rfc822', + ], + ])); + $this->assertSame('rfc822', $map->mapValueTimestampFormat); + + // Root timestamp form: + $root = $provider->get($this->shape(['type' => 'timestamp'])); + $this->assertSame('iso8601', $root->timestampFormat); + + $explicit = $provider->get($this->shape([ + 'type' => 'timestamp', 'timestampFormat' => 'rfc822', + ])); + $this->assertSame('rfc822', $explicit->timestampFormat); + } + + public function testLeafTypeTags(): void + { + // Cover every XmlShapeType::fromShape branch, including blob and + // boolean, plus the scalar default for numeric types. + $provider = new XmlEncodePlanProvider(); + $shape = $this->shape([ + 'type' => 'structure', + 'members' => [ + 'Body' => ['type' => 'blob'], + 'Flag' => ['type' => 'boolean'], + 'Count' => ['type' => 'integer'], + 'Ratio' => ['type' => 'double'], + 'Name' => ['type' => 'string'], + ], + ]); + $plan = $provider->get($shape); + + $this->assertSame(XmlShapeType::BLOB, $plan->members['Body'][XmlEncodePlan::M_TYPE]); + $this->assertSame(XmlShapeType::BOOLEAN, $plan->members['Flag'][XmlEncodePlan::M_TYPE]); + $this->assertSame(XmlShapeType::SCALAR, $plan->members['Count'][XmlEncodePlan::M_TYPE]); + $this->assertSame(XmlShapeType::SCALAR, $plan->members['Ratio'][XmlEncodePlan::M_TYPE]); + $this->assertSame(XmlShapeType::SCALAR, $plan->members['Name'][XmlEncodePlan::M_TYPE]); + } + + public function testCachesPlanPerShapeInXmlEncodeSlot(): void + { + $provider = new XmlEncodePlanProvider(); + $shape = $this->shape(['type' => 'structure', 'members' => []]); + + $first = $provider->get($shape); + $second = $provider->get($shape); + + $this->assertSame($first, $second); + $this->assertSame($first, $shape->getSerdePlan(ShapePlanCache::XML_ENCODE)); + // Does not collide with the JSON encode slot. + $this->assertNull($shape->getSerdePlan(ShapePlanCache::JSON_ENCODE)); + } +} diff --git a/tests/Api/Serde/Xml/XmlPlanInvalidationTest.php b/tests/Api/Serde/Xml/XmlPlanInvalidationTest.php new file mode 100644 index 0000000000..5ac89a5fa0 --- /dev/null +++ b/tests/Api/Serde/Xml/XmlPlanInvalidationTest.php @@ -0,0 +1,246 @@ + 'structure', 'name' => 'Root', 'members' => $members], + $map ?? new ShapeMap([]) + ); + } + + private function body(): XmlBody + { + return new XmlBody(new Service( + ['metadata' => ['protocol' => 'rest-xml'], 'shapes' => []], + function () { + return []; + } + )); + } + + public function testLocationNameMutationRebuildsEncodePlan(): void + { + $provider = new XmlEncodePlanProvider(); + $shape = $this->structure(['A' => ['type' => 'string', 'locationName' => 'old']]); + + $plan1 = $provider->get($shape); + $this->assertSame('old', $plan1->members['A'][XmlEncodePlan::M_ELEMENT]); + + $shape['members'] = ['A' => ['type' => 'string', 'locationName' => 'new']]; + + $plan2 = $provider->get($shape); + $this->assertNotSame($plan1, $plan2); + $this->assertSame('new', $plan2->members['A'][XmlEncodePlan::M_ELEMENT]); + } + + public function testLocationNameMutationRebuildsDecodePlan(): void + { + $provider = new XmlDecodePlanProvider(); + $shape = $this->structure(['A' => ['type' => 'string', 'locationName' => 'old']]); + + $plan1 = $provider->get($shape); + $this->assertSame('old', $plan1->members[0][XmlDecodePlan::M_NODE]); + + $shape['members'] = ['A' => ['type' => 'string', 'locationName' => 'new']]; + + $plan2 = $provider->get($shape); + $this->assertNotSame($plan1, $plan2); + $this->assertSame('new', $plan2->members[0][XmlDecodePlan::M_NODE]); + } + + public function testAttributeToggleRebuildsEncodePlan(): void + { + $provider = new XmlEncodePlanProvider(); + $shape = $this->structure(['A' => ['type' => 'string']]); + + $plan1 = $provider->get($shape); + $this->assertNull($plan1->members['A'][XmlEncodePlan::M_ATTR_NAME]); + $this->assertSame([], $plan1->attributeMembers); + + $shape['members'] = ['A' => ['type' => 'string', 'xmlAttribute' => true, 'locationName' => 'a']]; + + $plan2 = $provider->get($shape); + $this->assertSame('a', $plan2->members['A'][XmlEncodePlan::M_ATTR_NAME]); + $this->assertSame(['A'], $plan2->attributeMembers); + } + + public function testCoercionMutationRebuildsDecodePlan(): void + { + $provider = new XmlDecodePlanProvider(); + $shape = $this->structure(['A' => ['type' => 'string']]); + + $this->assertSame( + XmlDecodePlan::COERCE_STRING, + $provider->get($shape)->members[0][XmlDecodePlan::M_COERCE] + ); + + $shape['members'] = ['A' => ['type' => 'integer']]; + + $this->assertSame( + XmlDecodePlan::COERCE_INT, + $provider->get($shape)->members[0][XmlDecodePlan::M_COERCE] + ); + } + + public function testMutatingChildInvalidatesParentAndChildPlans(): void + { + // Parent and child share a ShapeMap generation, so mutating the child + // must also drop the parent's cached plan. + $map = new ShapeMap(['Child' => [ + 'type' => 'structure', + 'members' => ['X' => ['type' => 'string']], + ]]); + $parent = $this->structure(['C' => ['shape' => 'Child']], $map); + $provider = new XmlEncodePlanProvider(); + + $parentPlan = $provider->get($parent); + $child = $parent->getMember('C'); + $childPlan = $provider->get($child); + + $child['members'] = ['Y' => ['type' => 'string']]; + + $this->assertNotSame($childPlan, $provider->get($child)); + $this->assertNotSame($parentPlan, $provider->get($parent)); + $this->assertArrayHasKey('Y', $provider->get($child)->members); + } + + public function testListItemMutationRebuildsAttributeFields(): void + { + $list = new ListShape( + ['type' => 'list', 'member' => ['type' => 'string']], + new ShapeMap([]) + ); + $provider = new XmlEncodePlanProvider(); + + $plan1 = $provider->get($list); + $this->assertFalse($plan1->listItemAttribute); + $this->assertNull($plan1->listItemAttrName); + + $list['member'] = ['type' => 'string', 'xmlAttribute' => true, 'locationName' => 'it']; + + $plan2 = $provider->get($list); + $this->assertNotSame($plan1, $plan2); + $this->assertTrue($plan2->listItemAttribute); + $this->assertSame('it', $plan2->listItemAttrName); + } + + public function testMapValueMutationRebuildsAttributeFields(): void + { + $map = new MapShape( + ['type' => 'map', 'key' => ['type' => 'string'], 'value' => ['type' => 'string']], + new ShapeMap([]) + ); + $provider = new XmlEncodePlanProvider(); + + $plan1 = $provider->get($map); + $this->assertFalse($plan1->mapKeyAttribute); + $this->assertFalse($plan1->mapValueAttribute); + + $map['value'] = ['type' => 'string', 'xmlAttribute' => true]; + + $plan2 = $provider->get($map); + $this->assertNotSame($plan1, $plan2); + $this->assertFalse($plan2->mapKeyAttribute); + $this->assertTrue($plan2->mapValueAttribute); + } + + public function testOffsetUnsetInvalidatesPlan(): void + { + $provider = new XmlEncodePlanProvider(); + $shape = $this->structure(['A' => ['type' => 'string']]); + $shape['xmlNamespace'] = ['uri' => 'http://a']; + + $plan1 = $provider->get($shape); + $this->assertSame(['xmlns', 'http://a'], $plan1->namespace); + + unset($shape['xmlNamespace']); + + $plan2 = $provider->get($shape); + $this->assertNotSame($plan1, $plan2); + $this->assertNull($plan2->namespace); + } + + public function testStableGenerationReusesCachedPlans(): void + { + $shape = $this->structure(['A' => ['type' => 'string']]); + $encode = new XmlEncodePlanProvider(); + $decode = new XmlDecodePlanProvider(); + + $this->assertSame($encode->get($shape), $encode->get($shape)); + $this->assertSame($decode->get($shape), $decode->get($shape)); + } + + public function testRootNameIsResolvedOnBuildAndCachedOnRootPlan(): void + { + $shape = $this->structure([ + 'C' => ['type' => 'structure', 'members' => ['X' => ['type' => 'string']]], + ]); + $provider = new XmlEncodePlanProvider(); + + // Compiling never resolves a root name; nested shapes may have none. + $this->assertNull($provider->get($shape)->rootName); + + $body = $this->body(); + $body->build($shape, ['C' => ['X' => 'x']]); + + $rootPlan = $shape->getSerdePlan(ShapePlanCache::XML_ENCODE); + $this->assertSame('Root', $rootPlan->rootName); + $childPlan = $shape->getMember('C')->getSerdePlan(ShapePlanCache::XML_ENCODE); + $this->assertNull($childPlan->rootName); + } + + public function testEncodeFollowsMemberMutation(): void + { + $shape = $this->structure(['A' => ['type' => 'string', 'locationName' => 'old']]); + $body = $this->body(); + $prefix = '' . "\n"; + + $this->assertSame($prefix . "a\n", $body->build($shape, ['A' => 'a'])); + + $shape['members'] = ['A' => ['type' => 'string', 'locationName' => 'new']]; + $this->assertSame($prefix . "a\n", $body->build($shape, ['A' => 'a'])); + + $shape['members'] = ['A' => ['type' => 'string', 'xmlAttribute' => true]]; + $this->assertSame($prefix . "\n", $body->build($shape, ['A' => 'a'])); + } + + public function testDecodeFollowsMemberMutation(): void + { + $shape = $this->structure(['A' => ['type' => 'string', 'locationName' => 'old']]); + $parser = new XmlParser(); + $xml = new \SimpleXMLElement('12'); + + $this->assertSame(['A' => '1'], $parser->parse($shape, $xml)); + + $shape['members'] = ['A' => ['type' => 'string', 'locationName' => 'new']]; + $this->assertSame(['A' => '2'], $parser->parse($shape, $xml)); + + $shape['members'] = ['A' => ['type' => 'integer', 'locationName' => 'new']]; + $this->assertSame(['A' => 2], $parser->parse($shape, $xml)); + } +} diff --git a/tests/Api/Serde/Xml/XmlWireCompatibilityTest.php b/tests/Api/Serde/Xml/XmlWireCompatibilityTest.php new file mode 100644 index 0000000000..33bd527d13 --- /dev/null +++ b/tests/Api/Serde/Xml/XmlWireCompatibilityTest.php @@ -0,0 +1,460 @@ + ['protocol' => 'rest-xml'], 'shapes' => []], + function () { + return []; + } + ); + } + + private static function root(array $members, array $extra = []): Shape + { + return Shape::create( + ['type' => 'structure', 'name' => 'Root', 'members' => $members] + $extra, + new ShapeMap([]) + ); + } + + private static function encode(Shape $shape, array $args): string + { + return (new XmlBody(self::service()))->build($shape, $args); + } + + private static function doc(string $body): string + { + return '' . "\n" . $body . "\n"; + } + + /** + * Runs a callable and fails on any PHP warning, notice or deprecation. + */ + private function withoutWarnings(callable $fn) + { + set_error_handler(function ($errno, $errstr) { + $this->fail("Unexpected PHP error: {$errstr}"); + }); + try { + return $fn(); + } finally { + restore_error_handler(); + } + } + + public static function encodeProvider(): array + { + return [ + 'multiple attributes come first, in reverse input order' => [ + [ + 'E' => ['type' => 'string'], + 'B' => ['type' => 'string', 'xmlAttribute' => true], + 'A' => ['type' => 'string', 'xmlAttribute' => true], + ], + ['E' => 'e', 'B' => 'b', 'A' => 'a'], + // Each attribute member is prepended, so later ones lead. + 'e', + ], + 'namespace without prefix' => [ + ['C' => ['type' => 'structure', 'xmlNamespace' => ['uri' => 'http://a'], 'members' => [ + 'X' => ['type' => 'string'], + ]]], + ['C' => ['X' => 'x']], + 'x', + ], + 'namespace with prefix' => [ + ['C' => ['type' => 'structure', 'xmlNamespace' => ['uri' => 'http://a', 'prefix' => 'p'], 'members' => [ + 'X' => ['type' => 'string'], + ]]], + ['C' => ['X' => 'x']], + 'x', + ], + 'empty wrapped list' => [ + ['L' => ['type' => 'list', 'member' => ['type' => 'string']]], + ['L' => []], + '', + ], + 'empty flattened list' => [ + ['L' => ['type' => 'list', 'flattened' => true, 'member' => ['type' => 'string']]], + ['L' => []], + '', + ], + 'empty wrapped map' => [ + ['M' => ['type' => 'map', 'key' => ['type' => 'string'], 'value' => ['type' => 'string']]], + ['M' => []], + '', + ], + 'empty flattened map' => [ + ['M' => ['type' => 'map', 'flattened' => true, + 'key' => ['type' => 'string'], 'value' => ['type' => 'string']]], + ['M' => []], + '', + ], + 'flattened map' => [ + ['M' => ['type' => 'map', 'flattened' => true, + 'key' => ['type' => 'string'], 'value' => ['type' => 'string']]], + ['M' => ['a' => '1', 'b' => '2']], + 'a1b2', + ], + 'nested list of lists' => [ + ['L' => ['type' => 'list', 'member' => [ + 'type' => 'list', 'member' => ['type' => 'string'], + ]]], + ['L' => [['a', 'b'], ['c']]], + 'ab' + . 'c', + ], + 'map of lists' => [ + ['M' => ['type' => 'map', 'key' => ['type' => 'string'], 'value' => [ + 'type' => 'list', 'member' => ['type' => 'integer'], + ]]], + ['M' => ['k' => [1, 2]]], + 'k12', + ], + 'list of structures' => [ + ['L' => ['type' => 'list', 'member' => [ + 'type' => 'structure', 'locationName' => 'item', 'members' => [ + 'N' => ['type' => 'string'], + ], + ]]], + ['L' => [['N' => 'a'], ['N' => 'b']]], + 'ab', + ], + 'blob' => [ + ['B' => ['type' => 'blob']], + ['B' => 'hello'], + 'aGVsbG8=', + ], + 'timestamps in each format' => [ + [ + 'D' => ['type' => 'timestamp'], + 'E' => ['type' => 'timestamp', 'timestampFormat' => 'unixTimestamp'], + 'H' => ['type' => 'timestamp', 'timestampFormat' => 'rfc822'], + ], + ['D' => 0, 'E' => 0, 'H' => 0], + '1970-01-01T00:00:00Z0Thu, 01 Jan 1970 00:00:00 GMT', + ], + 'timestamp list uses item format' => [ + ['L' => ['type' => 'list', 'member' => [ + 'type' => 'timestamp', + 'timestampFormat' => 'unixTimestamp', + ]]], + ['L' => [0, 86400]], + '086400', + ], + 'timestamp map uses value format' => [ + ['M' => ['type' => 'map', + 'key' => ['type' => 'string'], + 'value' => [ + 'type' => 'timestamp', + 'timestampFormat' => 'rfc822', + ], + ]], + ['M' => ['epoch' => 0]], + 'epoch' + . 'Thu, 01 Jan 1970 00:00:00 GMT', + ], + 'special floating-point values' => [ + [ + 'A' => ['type' => 'double'], + 'B' => ['type' => 'double'], + 'C' => ['type' => 'float'], + ], + ['A' => 'NaN', 'B' => 'Infinity', 'C' => '-Infinity'], + 'NaNInfinity-Infinity', + ], + 'booleans' => [ + ['T' => ['type' => 'boolean'], 'F' => ['type' => 'boolean']], + ['T' => true, 'F' => false], + 'truefalse', + ], + 'union writes the set member' => [ + ['U' => ['type' => 'structure', 'union' => true, 'members' => [ + 'S' => ['type' => 'string'], + 'I' => ['type' => 'integer'], + ]]], + ['U' => ['I' => 4]], + '4', + ], + 'unknown input keys are dropped' => [ + ['A' => ['type' => 'string']], + ['Nope' => 'x', 'A' => 'a'], + 'a', + ], + ]; + } + + #[DataProvider('encodeProvider')] + public function testEncodeWireOutput(array $members, array $args, string $expected): void + { + $this->assertSame(self::doc($expected), self::encode(self::root($members), $args)); + } + + public function testInlineNestedShapesEncodeWithoutWarnings(): void + { + // Inline shapes have no 'name'. Only the root name may be resolved. + $shape = self::root([ + 'T' => ['type' => 'timestamp'], + 'L' => ['type' => 'list', 'member' => ['type' => 'integer']], + 'M' => ['type' => 'map', 'key' => ['type' => 'string'], 'value' => ['type' => 'string']], + 'C' => ['type' => 'structure', 'members' => ['X' => ['type' => 'string']]], + ]); + + $xml = $this->withoutWarnings(function () use ($shape) { + return self::encode($shape, [ + 'T' => 0, + 'L' => [1], + 'M' => ['k' => 'v'], + 'C' => ['X' => 'x'], + ]); + }); + + $this->assertSame( + self::doc( + '1970-01-01T00:00:00Z1' + . 'kvx' + ), + $xml + ); + } + + public function testRootNamePrefersShapeMapDefinitionLocationName(): void + { + $map = new ShapeMap(['Foo' => [ + 'type' => 'structure', + 'locationName' => 'FromDefinition', + 'members' => ['A' => ['type' => 'string']], + ]]); + $shape = $map->resolve(['shape' => 'Foo', 'locationName' => 'FromReference']); + + $this->assertSame( + self::doc('a'), + self::encode($shape, ['A' => 'a']) + ); + } + + public function testRootNameFallsBackToResolvedLocationName(): void + { + $map = new ShapeMap(['Foo' => [ + 'type' => 'structure', + 'members' => ['A' => ['type' => 'string']], + ]]); + $shape = $map->resolve(['shape' => 'Foo', 'locationName' => 'FromReference']); + + $this->assertSame( + self::doc('a'), + self::encode($shape, ['A' => 'a']) + ); + } + + public function testRootNameFallsBackToShapeName(): void + { + $map = new ShapeMap(['Foo' => [ + 'type' => 'structure', + 'members' => ['A' => ['type' => 'string']], + ]]); + $shape = $map->resolve(['shape' => 'Foo']); + + $this->assertSame( + self::doc('a'), + self::encode($shape, ['A' => 'a']) + ); + } + + /** + * Replaces DateTimeResult and SimpleXMLElement values with strings so + * results compare by value. + */ + private static function normalize($value) + { + if ($value instanceof DateTimeResult) { + return 'ts:' . $value->format('Y-m-d\TH:i:s\Z'); + } + if ($value instanceof \SimpleXMLElement) { + return 'xml:' . (string) $value; + } + if (is_array($value)) { + return array_map([self::class, 'normalize'], $value); + } + return $value; + } + + private static function decode(array $members, string $xml, array $extra = []) + { + /** @var StructureShape $shape */ + $shape = self::root($members, $extra); + return self::normalize( + (new XmlParser())->parse($shape, new \SimpleXMLElement($xml)) + ); + } + + public static function decodeProvider(): array + { + return [ + 'empty wrapped list' => [ + ['L' => ['type' => 'list', 'member' => ['type' => 'string']]], + '', + ['L' => []], + ], + 'absent flattened list' => [ + ['L' => ['type' => 'list', 'flattened' => true, 'member' => ['type' => 'string']]], + '', + [], + ], + 'empty wrapped map' => [ + ['M' => ['type' => 'map', 'key' => ['type' => 'string'], 'value' => ['type' => 'string']]], + '', + ['M' => []], + ], + 'flattened list' => [ + ['L' => ['type' => 'list', 'flattened' => true, 'member' => ['type' => 'string']]], + 'ab', + ['L' => ['a', 'b']], + ], + 'flattened map' => [ + ['M' => ['type' => 'map', 'flattened' => true, + 'key' => ['type' => 'string'], 'value' => ['type' => 'string']]], + 'a1b2', + ['M' => ['a' => '1', 'b' => '2']], + ], + 'nested list of lists' => [ + ['L' => ['type' => 'list', 'member' => [ + 'type' => 'list', 'member' => ['type' => 'integer'], + ]]], + '12' + . '3', + ['L' => [[1, 2], [3]]], + ], + 'map of structures' => [ + ['M' => ['type' => 'map', 'key' => ['type' => 'string'], 'value' => [ + 'type' => 'structure', 'members' => ['N' => ['type' => 'string']], + ]]], + 'kn', + ['M' => ['k' => ['N' => 'n']]], + ], + 'multiple attributes' => [ + [ + 'A' => ['type' => 'string', 'xmlAttribute' => true, 'locationName' => 'a'], + 'B' => ['type' => 'string', 'xmlAttribute' => true, 'locationName' => 'b'], + 'E' => ['type' => 'string'], + ], + 'e', + ['A' => '1', 'B' => '2', 'E' => 'e'], + ], + 'blob' => [ + ['B' => ['type' => 'blob']], + 'aGVsbG8=', + ['B' => 'hello'], + ], + 'timestamps' => [ + [ + 'D' => ['type' => 'timestamp'], + 'E' => ['type' => 'timestamp', 'timestampFormat' => 'unixTimestamp'], + ], + '1970-01-02T00:00:00Z86400', + ['D' => 'ts:1970-01-02T00:00:00Z', 'E' => 'ts:1970-01-02T00:00:00Z'], + ], + 'timestamp list' => [ + ['L' => ['type' => 'list', 'member' => [ + 'type' => 'timestamp', + 'timestampFormat' => 'unixTimestamp', + ]]], + '086400', + [ + 'L' => [ + 'ts:1970-01-01T00:00:00Z', + 'ts:1970-01-02T00:00:00Z', + ], + ], + ], + 'floating-point list' => [ + ['L' => ['type' => 'list', 'member' => ['type' => 'double']]], + '1.5NaN', + ['L' => [1.5, 'NaN']], + ], + 'special floating-point values' => [ + [ + 'A' => ['type' => 'double'], + 'B' => ['type' => 'double'], + 'C' => ['type' => 'float'], + 'D' => ['type' => 'float'], + ], + 'NaNInfinity-Infinity1.5', + ['A' => 'NaN', 'B' => 'Infinity', 'C' => '-Infinity', 'D' => 1.5], + ], + 'booleans and integers' => [ + ['T' => ['type' => 'boolean'], 'F' => ['type' => 'boolean'], 'I' => ['type' => 'integer']], + 'truefalse42', + ['T' => true, 'F' => false, 'I' => 42], + ], + ]; + } + + #[DataProvider('decodeProvider')] + public function testDecodeResult(array $members, string $xml, array $expected): void + { + $this->assertSame($expected, self::decode($members, $xml)); + } + + public function testDecodeAttributeWithoutLocationNameUsesMemberName(): void + { + // Compiling the plan must not pass a null locationName to str_replace(). + $result = $this->withoutWarnings(function () { + return self::decode( + [ + 'A' => ['type' => 'string', 'xmlAttribute' => true], + 'E' => ['type' => 'string'], + ], + 'e' + ); + }); + + $this->assertSame(['A' => '1', 'E' => 'e'], $result); + } + + public function testDecodeUnionKnownMember(): void + { + $this->assertSame( + ['S' => 's'], + self::decode( + ['S' => ['type' => 'string'], 'I' => ['type' => 'integer']], + 's', + ['union' => true] + ) + ); + } + + public function testDecodeUnionUnknownMember(): void + { + $this->assertSame( + ['Unknown' => ['Inner' => 'xml:v']], + self::decode( + ['S' => ['type' => 'string']], + 'v', + ['union' => true] + ) + ); + } +} diff --git a/tests/Api/Serializer/XmlBodyTest.php b/tests/Api/Serializer/XmlBodyTest.php new file mode 100644 index 0000000000..6d4fafbdfd --- /dev/null +++ b/tests/Api/Serializer/XmlBodyTest.php @@ -0,0 +1,150 @@ + [ + ['A' => ['type' => 'string', 'xmlAttribute' => true], 'B' => ['type' => 'string']], + ['B' => 'b', 'A' => 'a'], + 'b', + ], + 'string attribute uses locationName' => [ + ['A' => ['type' => 'string', 'xmlAttribute' => true, 'locationName' => 'xa']], + ['A' => 'a'], + '', + ], + 'string attribute with locationNameAtStructureLevel' => [ + ['A' => [ + 'type' => 'string', + 'xmlAttribute' => true, + 'locationName' => 'xa', + 'locationNameAtStructureLevel' => true, + ]], + ['A' => 'a'], + '', + ], + 'integer attribute is an element, ordered first' => [ + ['B' => ['type' => 'string'], 'N' => ['type' => 'integer', 'xmlAttribute' => true]], + ['B' => 'b', 'N' => 7], + '7b', + ], + 'long attribute is an element' => [ + ['N' => ['type' => 'long', 'xmlAttribute' => true, 'locationName' => 'n']], + ['N' => 9], + '9', + ], + 'float attribute is an element' => [ + ['N' => ['type' => 'float', 'xmlAttribute' => true]], + ['N' => 1.5], + '1.5', + ], + 'double attribute is an element' => [ + ['N' => ['type' => 'double', 'xmlAttribute' => true]], + ['N' => 2.25], + '2.25', + ], + 'boolean attribute is an element' => [ + ['N' => ['type' => 'boolean', 'xmlAttribute' => true]], + ['N' => true], + 'true', + ], + 'timestamp attribute is an element' => [ + ['N' => ['type' => 'timestamp', 'xmlAttribute' => true]], + ['N' => 0], + '1970-01-01T00:00:00Z', + ], + 'mixed attribute members' => [ + [ + 'S' => ['type' => 'string'], + 'I' => ['type' => 'integer', 'xmlAttribute' => true], + 'A' => ['type' => 'string', 'xmlAttribute' => true], + ], + ['S' => 's', 'I' => 3, 'A' => 'a'], + '3s', + ], + 'nested structure attribute members' => [ + ['C' => ['type' => 'structure', 'members' => [ + 'I' => ['type' => 'integer', 'xmlAttribute' => true], + 'A' => ['type' => 'string', 'xmlAttribute' => true], + ]]], + ['C' => ['I' => 1, 'A' => 'a']], + '1', + ], + 'flattened list of string attribute items' => [ + ['L' => ['type' => 'list', 'flattened' => true, 'member' => [ + 'type' => 'string', + 'xmlAttribute' => true, + 'locationName' => 'it', + ]]], + ['L' => ['x']], + '', + ], + 'list of integer attribute items are elements' => [ + ['L' => ['type' => 'list', 'member' => ['type' => 'integer', 'xmlAttribute' => true]]], + ['L' => [1, 2]], + '12', + ], + 'map string attribute key and value' => [ + ['M' => ['type' => 'map', + 'key' => ['type' => 'string', 'xmlAttribute' => true], + 'value' => ['type' => 'string', 'xmlAttribute' => true, 'locationName' => 'v'], + ]], + ['M' => ['k' => 'v1']], + '', + ], + 'map integer attribute value is an element' => [ + ['M' => ['type' => 'map', + 'key' => ['type' => 'string'], + 'value' => ['type' => 'integer', 'xmlAttribute' => true], + ]], + ['M' => ['k' => 5]], + 'k5', + ], + ]; + } + + #[DataProvider('xmlAttributeProvider')] + public function testXmlAttributeSerialization( + array $members, + array $args, + string $expected + ): void { + $shape = Shape::create( + [ + 'type' => 'structure', + 'name' => 'Root', + 'members' => $members, + ], + new ShapeMap([]) + ); + $service = new Service( + ['metadata' => ['protocol' => 'rest-xml'], 'shapes' => []], + function () { + return []; + } + ); + + $xml = (new XmlBody($service))->build($shape, $args); + + $this->assertSame( + '' . "\n" . $expected . "\n", + $xml + ); + } +}