diff --git a/README.md b/README.md index 835c077..2313a64 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,8 @@ backend.add(1, 2).then((response) => { }); ``` +If a method return type is a PHP class with public properties, JSON help now also includes `return_structure`, and the generated TypeScript client maps that class to an object shape based on those public properties. + These outputs can also be generated in command line: * HTML: `$> php APIcalculator.php --html` diff --git a/src/APIlite.php b/src/APIlite.php index c22c714..f8f9d40 100644 --- a/src/APIlite.php +++ b/src/APIlite.php @@ -87,7 +87,8 @@ class APIlite 'doc' => null, 'description' => null, 'params' => array(), - 'return' => null + 'return' => null, + 'return_structure' => null ); $docComment = $ref_method->getDocComment(); if ($docComment) { @@ -115,27 +116,129 @@ class APIlite } if ($ref_method->hasReturnType()) { $ref_type = $ref_method->getReturnType(); - if ($ref_type instanceof \ReflectionNamedType) { - $method['return'] = $ref_type->getName(); - } - if ($ref_type instanceof \ReflectionUnionType - || $ref_type instanceof \ReflectionIntersectionType ) - { - $types = $ref_type->getTypes(); - $method['return'] = []; - foreach ($types as $type) { - if ($type instanceof \ReflectionNamedType) { - $method['return'][] = $type->getName(); - } - } - - } + $method['return'] = $this->reflectionTypeToNames($ref_type, $ref_method->getDeclaringClass()); + $method['return_structure'] = $this->reflectionTypeToStructure($ref_type, $ref_method->getDeclaringClass()); } $this->methods[] = $method; } return true; } + private function reflectionTypeToNames(?\ReflectionType $ref_type, ?\ReflectionClass $context_class = null): string|array|null + { + if ($ref_type instanceof \ReflectionNamedType) { + $type_name = $this->resolveTypeName($ref_type->getName(), $context_class); + if ($ref_type->allowsNull() && strtolower($type_name) !== 'null' && strtolower($type_name) !== 'mixed') { + return array($type_name, 'null'); + } + return $type_name; + } + if ($ref_type instanceof \ReflectionUnionType || $ref_type instanceof \ReflectionIntersectionType) { + $types = array(); + foreach ($ref_type->getTypes() as $type) { + if (!$type instanceof \ReflectionNamedType) { + continue; + } + $one_type = $this->reflectionTypeToNames($type, $context_class); + if (is_array($one_type)) { + $types = array_merge($types, $one_type); + } else if (is_string($one_type)) { + $types[] = $one_type; + } + } + $types = array_values(array_unique($types)); + return empty($types) ? null : $types; + } + return null; + } + + private function reflectionTypeToStructure(?\ReflectionType $ref_type, ?\ReflectionClass $context_class = null, array $visited = array()): array|null + { + if ($ref_type instanceof \ReflectionNamedType) { + return $this->classTypeStructure( + $this->resolveTypeName($ref_type->getName(), $context_class), + $visited + ); + } + if ($ref_type instanceof \ReflectionUnionType || $ref_type instanceof \ReflectionIntersectionType) { + $structures = array(); + foreach ($ref_type->getTypes() as $type) { + if (!$type instanceof \ReflectionNamedType) { + continue; + } + $structure = $this->reflectionTypeToStructure($type, $context_class, $visited); + if (!is_null($structure)) { + $structures[] = $structure; + } + } + return count($structures) > 0 ? $structures : null; + } + return null; + } + + private function classTypeStructure(string $type_name, array $visited = array()): array|null + { + $type_name = ltrim($type_name, '\\'); + if ($this->isBuiltinTypeName($type_name) || !class_exists($type_name)) { + return null; + } + if (in_array($type_name, $visited, true)) { + return array( + 'type' => $type_name, + 'recursive' => true, + 'properties' => array() + ); + } + + $visited[] = $type_name; + $refClass = new \ReflectionClass($type_name); + $properties = array(); + foreach ($refClass->getProperties(\ReflectionProperty::IS_PUBLIC) as $ref_property) { + if ($ref_property->isStatic()) { + continue; + } + $property_type = $ref_property->getType(); + $properties[] = array( + 'name' => $ref_property->getName(), + 'type' => $this->reflectionTypeToNames($property_type, $refClass), + 'nullable' => is_null($property_type) ? true : $property_type->allowsNull(), + 'type_structure' => $this->reflectionTypeToStructure($property_type, $refClass, $visited) + ); + } + + return array( + 'type' => $type_name, + 'properties' => $properties + ); + } + + private function resolveTypeName(string $type_name, ?\ReflectionClass $context_class = null): string + { + $type_name = ltrim($type_name, '\\'); + if (is_null($context_class)) { + return $type_name; + } + switch (strtolower($type_name)) { + case 'self': + case 'static': + return $context_class->getName(); + case 'parent': + $parent = $context_class->getParentClass(); + return $parent ? $parent->getName() : $type_name; + default: + return $type_name; + } + } + + private function isBuiltinTypeName(string $type_name): bool + { + return in_array(strtolower(ltrim($type_name, '\\')), array( + 'array', 'bool', 'boolean', 'callable', 'closure', 'false', 'float', 'int', 'integer', + 'iterable', 'mixed', 'never', 'null', 'object', 'resource', 'scalar', 'string', + 'true', 'void' + ), true); + } + private function parseDescription(string $doc): string { $lines = explode("\n", $doc); diff --git a/src/help.tpl.php b/src/help.tpl.php index 006f948..891f032 100644 --- a/src/help.tpl.php +++ b/src/help.tpl.php @@ -1,3 +1,50 @@ +'; + $html .= '
' . htmlspecialchars($item['type']) . '
'; + if (!empty($item['recursive'])) { + $html .= '
Recursive reference
'; + } else if (!empty($item['properties']) && is_array($item['properties'])) { + $html .= ''; + } else { + $html .= '
No public properties
'; + } + $html .= ''; + } + return $html; +}; +?> @@ -461,6 +508,7 @@ + diff --git a/src/typescript.tpl.php b/src/typescript.tpl.php index 09ae633..22a38fd 100644 --- a/src/typescript.tpl.php +++ b/src/typescript.tpl.php @@ -33,6 +33,65 @@ $mapUnionType = function (mixed $type) use ($mapType): string { return $mapType(is_string($type) ? $type : null); }; + +$normalizeTypeStructures = function (mixed $structure): array { + if (!is_array($structure)) { + return array(); + } + if (array_key_exists('type', $structure) && array_key_exists('properties', $structure)) { + return array($structure); + } + return array_values(array_filter($structure, 'is_array')); +}; + +$findTypeStructure = function (?string $type, mixed $structure) use ($normalizeTypeStructures): array|null { + if (!is_string($type) || $type === '') { + return null; + } + $type = ltrim($type, '\\'); + foreach ($normalizeTypeStructures($structure) as $one_structure) { + if (($one_structure['type'] ?? null) === $type) { + return $one_structure; + } + } + return null; +}; + +$mapTypeWithStructure = null; +$mapUnionTypeWithStructure = null; + +$mapTypeWithStructure = function (?string $type, mixed $structure) use ($mapType, &$mapUnionTypeWithStructure): string { + if (is_array($structure) && array_key_exists('type', $structure) && array_key_exists('properties', $structure)) { + $properties = array(); + foreach (($structure['properties'] ?? array()) as $property) { + if (!is_array($property) || !isset($property['name'])) { + continue; + } + $propertyType = $mapUnionTypeWithStructure($property['type'] ?? null, $property['type_structure'] ?? null); + if (!empty($property['nullable']) && strpos($propertyType, 'null') === false) { + $propertyType .= ' | null'; + } + $properties[] = $property['name'] . ': ' . $propertyType; + } + return empty($properties) ? 'Record' : '{ ' . implode('; ', $properties) . ' }'; + } + + return $mapType($type); +}; + +$mapUnionTypeWithStructure = function (mixed $type, mixed $structure) use ($mapType, $findTypeStructure, &$mapTypeWithStructure): string { + if (is_array($type)) { + $parts = array(); + foreach ($type as $singleType) { + $typeName = is_string($singleType) ? $singleType : null; + $parts[] = $mapTypeWithStructure($typeName, $findTypeStructure($typeName, $structure)); + } + $parts = array_values(array_unique($parts)); + return empty($parts) ? 'unknown' : implode(' | ', $parts); + } + + return $mapTypeWithStructure(is_string($type) ? $type : null, $structure); +}; ?> /** * Generated by APIlite @@ -66,6 +125,20 @@ export interface APIliteMethodDoc { description: string | null; params: APIliteMethodParam[]; return: string | string[] | null; + return_structure: APIliteTypeStructure | APIliteTypeStructure[] | null; +} + +export interface APIliteTypeStructureProperty { + name: string; + type: string | string[] | null; + nullable: boolean; + type_structure: APIliteTypeStructure | APIliteTypeStructure[] | null; +} + +export interface APIliteTypeStructure { + type: string; + recursive?: boolean; + properties: APIliteTypeStructureProperty[]; } export interface APIliteHelpResponse { @@ -149,7 +222,7 @@ class apiName; ?> { $paramsSignature[] = $param['name'] . ($param['optional'] ? '?' : '') . ': ' . $paramType; $paramsPayload[] = $param['name']; } - $returnType = $mapUnionType($method['return']); + $returnType = $mapUnionTypeWithStructure($method['return'], $method['return_structure'] ?? null); ?> (): Promise>> { return this.callPromise>>('', { });