From 4c800ede12e556437d95691b7f14c9d5a8dde462 Mon Sep 17 00:00:00 2001 From: Matt Radford Date: Wed, 6 Dec 2023 17:12:55 -0800 Subject: [PATCH] Add public static serialization function to Serializer --- src/Serializer.php | 160 +++++++++++++++++++++++++++++++++++++++ tests/SerializerTest.php | 51 +++++++++++++ 2 files changed, 211 insertions(+) diff --git a/src/Serializer.php b/src/Serializer.php index 71f9b2b9..3dccffd6 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -60,6 +60,166 @@ class Serializer OA\XmlContent::class, ]; + private const GETTER_PREFIX = 'get'; + + /** + * @param array $context Sent to the getter function's arguments, allowing functionality similar to https://github.com/Crell/Serde#scopes + */ + public static function openapiSerialize(object|array $resource, array $context = []): string|array + { + if (is_array($resource)) { + $result = []; + + foreach ($resource as $k => $v) { + $value = (is_object($v) || is_array($v)) ? self::openapiSerialize($v, $context) : $v; + + $result[$k] = $value; + } + + return $result; + } + + if ($resource instanceof \DateTimeInterface) { + return $resource->format(\DateTimeInterface::RFC3339_EXTENDED); + } + + $serialized = []; + + foreach (array_merge(self::getReflectionProperties($resource), self::getReflectionFunctions($resource)) as $reflection) { + $key_value_pair = self::getKeyValuePair($resource, $reflection, $context); + + if ($key_value_pair) { + [$key, $value] = $key_value_pair; + + $serialized[$key] = $value; + } + } + + return $serialized; + } + + /** + * @return list<\ReflectionMethod> + */ + private static function getReflectionFunctions(object $resource): array + { + $reflection = new \ReflectionClass($resource); + + $getInterfaceNames = fn (array $carry, \ReflectionClass $rc) => array_merge($carry, $rc->getMethods()); + + $interface_reflection_functions = array_reduce($reflection->getInterfaces(), $getInterfaceNames, []); + + $resource_reflection_functions = $reflection->getMethods(\ReflectionProperty::IS_PUBLIC); + + return array_merge($interface_reflection_functions, $resource_reflection_functions); + } + + /** + * @return list<\ReflectionProperty> + */ + private static function getReflectionProperties(object $resource): array + { + $reflection = new \ReflectionClass($resource); + + $reflection_properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_READONLY); + + return array_values( + array_filter($reflection_properties, fn ( + \ReflectionProperty $reflection_property + ) => !$reflection_property->isStatic()) + ); + } + + private static function getKeyValuePair(object|array $resource, \ReflectionMethod|\ReflectionProperty $reflection, array $context): ?array + { + $openapi_reflection_attributes = $reflection->getAttributes(Attributes\Property::class); + + foreach ($openapi_reflection_attributes as $openapi_reflection_attribute) { + $openapi_attribute = $openapi_reflection_attribute->newInstance(); + + $key_value_pair = $reflection instanceof \ReflectionMethod ? + self::attemptGetter($resource, $context, $openapi_attribute->property) : + self::attemptProperty($resource, $reflection, $context, $openapi_attribute->property); + + if ($key_value_pair) { + return $key_value_pair; + } + } + + return null; + } + + /** + * @param object|array $resource + * @param array $context + * + * @return ?array{string, mixed} + */ + private static function attemptProperty(object|array $resource, \ReflectionProperty $reflection, array $context, string $key): ?array + { + if (!is_object($resource)) { + return null; + } + + $value = $reflection->getValue($resource); + + if ($key === Generator::UNDEFINED) { + $key = $reflection->getName(); + } + + if (is_object($value) || is_array($value)) { + $value = self::openapiSerialize($value, $context); + } + + return [$key, $value]; + } + + /** + * @return array|null If a getter function exists, return the key[0] and value[1], otherwise null + */ + private static function attemptGetter(object|array $resource, array $context, string $key): ?array + { + $found_value = $value = false; + + $normalized_key = ucfirst(self::studly($key)); + + $function_name = self::GETTER_PREFIX . $normalized_key; + + $function_name = (string) preg_replace('/[^A-Za-z0-9]/', '', (string) $function_name); + + if (method_exists($resource, $function_name)) { + $parameters = (new \ReflectionClass($resource))->getMethod($function_name)->getParameters(); + + $param_names = array_map(fn ($reflection_parameter) => $reflection_parameter->getName(), $parameters); + + // TODO: Handle/ignore optional params + $param_filtered_context = array_intersect_key($context, array_flip($param_names)); + + if ($param_names == array_keys($param_filtered_context)) { + $value = $resource->{$function_name}(...$param_filtered_context); + + $found_value = true; + } + } + + if ($found_value) { + $value = (is_object($value) || is_array($value)) ? self::openapiSerialize($value, $context) : $value; + + return [$key, $value]; + } + + return null; + } + + private static function studly(string $value): string + { + $words = explode(' ', str_replace(['-', '_'], ' ', $value)); + + $studlyWords = array_map(fn ($word) => ucfirst($word), $words); + + return implode($studlyWords); + } + protected static function isValidAnnotationClass(string $className): bool { return in_array($className, self::$VALID_ANNOTATIONS); diff --git a/tests/SerializerTest.php b/tests/SerializerTest.php index e475a11c..d4918f4c 100644 --- a/tests/SerializerTest.php +++ b/tests/SerializerTest.php @@ -7,6 +7,7 @@ namespace OpenApi\Tests; use OpenApi\Annotations as OA; +use OpenApi\Attributes; use OpenApi\Generator; use OpenApi\Serializer; @@ -210,4 +211,54 @@ public function testValidAnnotationsListComplete(string $annotation): void $staticProperties = (new \ReflectionClass((Serializer::class)))->getStaticProperties(); $this->assertArrayHasKey($annotation, array_flip($staticProperties['VALID_ANNOTATIONS'])); } + + public function testBasicSerialization(): void + { + $sample = new class() { + #[Attributes\Property(property: 'greeting')] + public function getGreeting(): string + { + return 'hello world'; + } + }; + + $serialized = Serializer::openapiSerialize($sample); + + $this->assertEquals(['greeting' => 'hello world'], $serialized); + } + + public function testSnakeCasePropertyNamesAreOk(): void + { + $sample = new class() { + #[Attributes\Property(property: 'another_greeting')] + public function getAnotherGreeting(): string + { + return 'hola mundo'; + } + }; + + $serialized = Serializer::openapiSerialize($sample); + + $this->assertEquals(['another_greeting' => 'hola mundo'], $serialized); + } + + public function testClassProperiesAreAbleToBeSerialized(): void + { + $sample = new class() { + public function __construct( + #[Attributes\Property] + public readonly string $greeting = 'hello world', + #[Attributes\Property( + property: 'another_greeting', + )] + public readonly string $_greeting = 'hello world', + public readonly bool $not_serialized = true, + ) { + } + }; + + $serialized = Serializer::openapiSerialize($sample); + + $this->assertEquals(['greeting' => 'hello world', 'another_greeting' => 'hello world'], $serialized); + } }