OpenAPI Generator. Better DocBlock parsing [WIP]

This introduces a new DocBlock parser to properly generate API
specifications. It also introduces the concept of Response classes to
better specify the response format.

This is still very much in progress.
This commit is contained in:
Andreas Gohr 2023-12-22 09:30:59 +01:00
parent fe9f11e2d0
commit 8ddd9b6918
15 changed files with 1005 additions and 8 deletions

View File

@ -0,0 +1,30 @@
<?php
namespace dokuwiki\test\Remote\OpenApiDoc;
use dokuwiki\Remote\OpenApiDoc\ClassResolver;
class ClassResolverTest extends \DokuWikiTest
{
public function testResolving()
{
$resolver = new ClassResolver();
// resolve by use statement
$this->assertEquals(ClassResolver::class, $resolver->resolve('ClassResolver', self::class));
// resolve in same namespace
$this->assertEquals(
'dokuwiki\test\Remote\OpenApiDoc\Something\Else',
$resolver->resolve('Something\Else', self::class)
);
// resolve fully qualified
$this->assertEquals(
'fully\Qualified\Class',
$resolver->resolve('\fully\Qualified\Class', self::class)
);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace dokuwiki\test\Remote\OpenApiDoc;
use dokuwiki\Remote\OpenApiDoc\DocBlockClass;
use dokuwiki\Remote\OpenApiDoc\DocBlockMethod;
use dokuwiki\Remote\OpenApiDoc\DocBlockProperty;
/**
* Test cases for DocBlockClass
*
* This test class is also used in the tests itself
*/
class DocBlockClassTest extends \DokuWikiTest
{
/** @var string This is a dummy */
public $dummyProperty1 = 'dummy';
/**
* Parse this test class with the DocBlockClass
*
* Also tests property and method access
*
* @return void
*/
public function testClass()
{
$reflect = new \ReflectionClass($this);
$doc = new DocBlockClass($reflect);
$this->assertStringContainsString('Test cases for DocBlockClass', $doc->getSummary());
$this->assertStringContainsString('used in the tests itself', $doc->getDescription());
$this->assertInstanceOf(DocBlockProperty::class, $doc->getPropertyDocs()['dummyProperty1']);
$this->assertEquals('This is a dummy', $doc->getPropertyDocs()['dummyProperty1']->getSummary());
$this->assertInstanceOf(DocBlockMethod::class, $doc->getMethodDocs()['testClass']);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace dokuwiki\test\Remote\OpenApiDoc;
use dokuwiki\Remote\OpenApiDoc\DocBlockMethod;
class DocBlockMethodTest extends \DokuWikiTest {
/**
* This is a test
*
* With more information
* in several lines
* @param string $foo First variable
* @param int $bar
* @param string[] $baz
* @something else
* @something other
* @another tag
* @return string The return
*/
public function dummyMethod1($foo, $bar, $baz=['a default'])
{
return 'dummy';
}
public function testMethod()
{
$reflect = new \ReflectionMethod($this, 'dummyMethod1');
$doc = new DocBlockMethod($reflect);
$this->assertEquals('This is a test', $doc->getSummary());
$this->assertEquals("With more information\nin several lines", $doc->getDescription());
$this->assertEquals(
[
'foo' => [
'type' => 'string',
'description' => 'First variable',
'optional' => false,
],
'bar' => [
'type' => 'int',
'description' => '',
'optional' => false,
],
'baz' => [
'type' => 'string[]',
'description' => '',
'optional' => true,
'default' => ['a default'],
],
],
$doc->getTag('param')
);
$this->assertEquals(
[
'type' => 'string',
'description' => 'The return'
],
$doc->getTag('return')
);
$this->assertEquals(
[
'else',
'other',
],
$doc->getTag('something')
);
$this->assertEquals(
[
'tag',
],
$doc->getTag('another')
);
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace dokuwiki\test\Remote\OpenApiDoc;
use dokuwiki\Remote\OpenApiDoc\Type;
class TypeTest extends \DokuWikiTest
{
public function provideBaseTypes()
{
return [
['string', 'string', 'string'],
['string', 'string', 'string', self::class],
['int', 'int', 'int'],
['int', 'int', 'int', self::class],
['file', 'string', 'file'],
['file', 'string', 'file', self::class],
['date', 'int', 'date'],
['date', 'int', 'date', self::class],
['boolean', 'bool', 'bool'],
['boolean', 'bool', 'bool', self::class],
['false', 'bool', 'bool'],
['false', 'bool', 'bool', self::class],
['true', 'bool', 'bool'],
['true', 'bool', 'bool', self::class],
['integer', 'int', 'int'],
['integer', 'int', 'int', self::class],
['array', 'array', 'array'],
['array', 'array', 'array', self::class],
['array[]', 'array', 'array'],
['array[]', 'array', 'array', self::class],
['foo', 'foo', 'object'],
['foo', 'foo', 'object', self::class],
['foo[]', 'array', 'array'],
['foo[]', 'array', 'array', self::class],
['Foo', 'Foo', 'object'],
['Foo', 'dokuwiki\\test\\Remote\\OpenApiDoc\\Foo', 'object', self::class],
['\\Foo', 'Foo', 'object'],
['\\Foo', 'Foo', 'object', self::class],
];
}
/**
* @dataProvider provideBaseTypes
* @param $typehint
* @param $expectedJSONRPCType
* @param $expectedXMLRPCType
* @param $context
* @return void
*/
public function testJSONBaseTypes($typehint, $expectedJSONRPCType, $expectedXMLRPCType, $context = '')
{
$type = new Type($typehint, $context);
$this->assertEquals($expectedJSONRPCType, $type->getJSONRPCType());
}
/**
* @dataProvider provideBaseTypes
* @param $typehint
* @param $expectedJSONRPCType
* @param $expectedXMLRPCType
* @param $context
* @return void
*/
public function testXMLBaseTypes($typehint, $expectedJSONRPCType, $expectedXMLRPCType, $context = '')
{
$type = new Type($typehint, $context);
$this->assertEquals($expectedXMLRPCType, $type->getXMLRPCType());
}
public function provideSubTypes()
{
return [
['string', ['string']],
['string[]', ['array', 'string']],
['string[][]', ['array', 'array', 'string']],
['array[][]', ['array', 'array', 'array']],
['Foo[][]', ['array', 'array', 'Foo']],
['Foo[][]', ['array', 'array', 'dokuwiki\\test\\Remote\\OpenApiDoc\\Foo'], self::class],
];
}
/**
* @dataProvider provideSubTypes
* @param $typehint
* @param $expected
* @param $context
* @return void
*/
public function testSubType($typehint, $expected, $context = '')
{
$type = new Type($typehint, $context);
$result = [$type->getJSONRPCType()];
while ($type = $type->getSubType()) {
$result[] = $type->getJSONRPCType();
}
$this->assertEquals($expected, $result);
}
}

View File

@ -254,6 +254,10 @@ class ApiCall
$this->return['type'] = 'string';
}
if (isset($docInfo['return']['response'])) {
$this->return['response'] = $docInfo['return']['response'];
}
if (isset($docInfo['return']['description'])) {
$this->return['description'] = $docInfo['return']['description'];
}
@ -328,6 +332,7 @@ class ApiCall
[$type, $description] = array_map('trim', sexplode(' ', $return, 2, ''));
$result['return'] = [
'type' => $this->cleanTypeHint($type),
'response' => $type, // uncleaned
'description' => $description
];
unset($tags['return']);

View File

@ -7,6 +7,7 @@ use dokuwiki\ChangeLog\MediaChangeLog;
use dokuwiki\ChangeLog\PageChangeLog;
use dokuwiki\Extension\AuthPlugin;
use dokuwiki\Extension\Event;
use dokuwiki\Remote\Response\Page;
use dokuwiki\Utf8\Sort;
/**
@ -211,12 +212,7 @@ class ApiCore
if ($perm < AUTH_READ) {
continue;
}
$page = [];
$page['id'] = trim($pages[$idx]);
$page['perms'] = $perm;
$page['size'] = @filesize(wikiFN($pages[$idx]));
$page['lastModified'] = $this->api->toDate(@filemtime(wikiFN($pages[$idx])));
$list[] = $page;
$list[] = new Page(['id' => $pages[$idx], 'perm' => $perm]);
}
return $list;
@ -229,7 +225,7 @@ class ApiCore
* @param array $opts
* $opts['depth'] recursion level, 0 for all
* $opts['hash'] do md5 sum of content?
* @return array[] A list of matching pages with id, rev, mtime, size, (hash)
* @return Page[] A list of matching pages with id, rev, mtime, size, (hash)
*/
public function readNamespace($ns, $opts = [])
{
@ -242,7 +238,11 @@ class ApiCore
$data = [];
$opts['skipacl'] = 0; // no ACL skipping for XMLRPC
search($data, $conf['datadir'], 'search_allpages', $opts, $dir);
return $data;
$result = array_map(fn($item) => new Page($item), $data);
return $result;
}
/**

View File

@ -78,6 +78,38 @@ class OpenAPIGenerator
}
}
protected function addComponents()
{
$schemas = [];
$files = glob(DOKU_INC . 'inc/Remote/Response/*.php');
foreach ($files as $file) {
$name = basename($file, '.php');
$class = 'dokuwiki\\Remote\\Response\\' . $name;
$reflection = new \ReflectionClass($class);
if($reflection->isAbstract()) continue;
$classDoc = new OpenApiDoc\DocBlockClass($reflection);
$schemas[$name] = [
'type' => 'object',
'summary' => $classDoc->getSummary(),
'description' => $classDoc->getDescription(),
'properties' => [],
];
foreach ($classDoc->getPropertyDocs() as $property => $doc) {
$schemas[$name]['properties'][$property] = [
'type' => $this->fixTypes($doc->getTag('type')),
'description' => $doc->getSummary(),
];
}
}
}
protected function getMethodDefinition(string $method, ApiCall $call)
{
$retType = $this->fixTypes($call->getReturn()['type']);
@ -168,6 +200,9 @@ class OpenAPIGenerator
return $schema;
}
protected function fixTypes($type)
{
switch ($type) {

View File

@ -0,0 +1,200 @@
<?php
namespace dokuwiki\Remote\OpenApiDoc;
class ClassResolver
{
/** @var ClassResolver */
private static $instance;
protected $classUses = [];
protected $classDocs = [];
/**
* @internal Use ClassResolver::getInstance() instead
*/
public function __construct()
{
}
/**
* Get a singleton instance
*
* Constructor is public for testing purposes
* @return ClassResolver
*/
public static function getInstance()
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Resolve a class name to a fully qualified class name
*
* Results are cached in the instance for reuse
*
* @param string $classalias The class name to resolve
* @param string $context The classname in which context in which the class is used
* @return string No guarantee that the class exists! No leading backslash!
*/
public function resolve($classalias, $context)
{
if ($classalias[0] === '\\') {
// Fully qualified class name given
return ltrim($classalias, '\\');
}
$classinfo = $this->getClassUses($context);
if (isset($classinfo['uses'][$classalias])) {
return $classinfo['uses'][$classalias];
}
return $classinfo['ownNS'] . '\\' . $classalias;
}
/**
* Resolve a class name to a fully qualified class name and return a DocBlockClass for it
*
* Results are cached in the instance for reuse
*
* @param string $classalias The class name to resolve
* @param string $context The classname in which context in which the class is used
* @return DocBlockClass|null
*/
public function document($classalias, $context)
{
$class = $this->resolve($classalias, $context);
if(!class_exists($class)) return null;
if(isset($this->classDocs[$class])) {
$reflector = new \ReflectionClass($class);
$this->classDocs[$class] = new DocBlockClass($reflector);
}
return $this->classDocs[$class];
}
/**
* Cached fetching of all defined class aliases
*
* @param string $class The class to parse
* @return array
*/
public function getClassUses($class)
{
if (!isset($this->classUses[$class])) {
$reflector = new \ReflectionClass($class);
$source = $this->readSource($reflector->getFileName(), $reflector->getStartLine());
$this->classUses[$class] = [
'ownNS' => $reflector->getNamespaceName(),
'uses' => $this->tokenizeSource($source)
];
}
return $this->classUses[$class];
}
/**
* Parse the use statements from the given source code
*
* This is a simplified version of the code by @jasondmoss - we do not support multiple
* classed within one file
*
* @link https://gist.github.com/jasondmoss/6200807
* @param string $source
* @return array
*/
private function tokenizeSource($source)
{
$tokens = token_get_all($source);
$useStatements = [];
$record = false;
$currentUse = [
'class' => '',
'as' => ''
];
foreach ($tokens as $token) {
if (!is_array($token)) {
// statement ended
if ($record) {
$useStatements[] = $currentUse;
$record = false;
$currentUse = [
'class' => '',
'as' => ''
];
}
continue;
}
$tokenname = token_name($token[0]);
if ($token[0] === T_CLASS) {
break; // we reached the class itself, no need to parse further
}
if ($token[0] === T_USE) {
$record = 'class';
continue;
}
if ($token[0] === T_AS) {
$record = 'as';
continue;
}
if ($record) {
switch ($token[0]) {
case T_STRING:
case T_NS_SEPARATOR:
case T_NAME_QUALIFIED:
$currentUse[$record] .= $token[1];
break;
}
}
}
// Return a lookup table alias to FQCN
$table = [];
foreach ($useStatements as $useStatement) {
$class = $useStatement['class'];
$alias = $useStatement['as'] ?: substr($class, strrpos($class, '\\') + 1);
$table[$alias] = $class;
}
return $table;
}
/**
* Read file source up to the line where our class is defined.
*
* @return string
*/
protected function readSource($file, $startline)
{
$file = fopen($file, 'r');
$line = 0;
$source = '';
while (!feof($file)) {
++$line;
if ($line >= $startline) {
break;
}
$source .= fgets($file);
}
fclose($file);
return $source;
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace dokuwiki\Remote\OpenApiDoc;
use Reflector;
class DocBlock
{
/** @var Reflector The reflected object */
protected $reflector;
/** @var string The first line of the decription */
protected $summary = '';
/** @var string The description */
protected $description = '';
/** @var string The parsed tags */
protected $tags = [];
/**
* Parse the given docblock
*
* The docblock can be of a method, class or property.
*
* @param Reflector $reflector
*/
public function __construct(Reflector $reflector)
{
$this->reflector = $reflector;
$docblock = $reflector->getDocComment();
// strip asterisks and leading spaces
$docblock = trim(preg_replace(
['/^[ \t]*\/\*+[ \t]*/m', '/[ \t]*\*+[ \t]*/m', '/\*+\/\s*$/m', '/\s*\/\s*$/m'],
['', '', '', ''],
$docblock
));
// get all tags
$tags = [];
if (preg_match_all('/^@(\w+)\s+(.*)$/m', $docblock, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$tags[$match[1]][] = trim($match[2]);
}
}
// strip the tags from the docblock
$docblock = preg_replace('/^@(\w+)\s+(.*)$/m', '', $docblock);
// what remains is summary and description
[$summary, $description] = sexplode("\n\n", $docblock, 2, '');
// store everything
$this->summary = trim($summary);
$this->description = trim($description);
$this->tags = $tags;
}
/**
* The class name of the declaring class
*
* @return string
*/
protected function getContext()
{
return $this->reflector->getDeclaringClass()->getName();
}
/**
* @return string
*/
public function getSummary(): string
{
return $this->summary;
}
/**
* @return string
*/
public function getDescription(): string
{
return $this->description;
}
/**
* Get all tags
*
* @return array
*/
public function getTags()
{
return $this->tags;
}
/**
* Get a specific tag
*
* @param string $tag
* @return array
*/
public function getTag($tag)
{
if (!isset($this->tags[$tag])) return [];
return $this->tags[$tag];
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace dokuwiki\Remote\OpenApiDoc;
use ReflectionClass;
class DocBlockClass extends DocBlock
{
/** @var DocBlockMethod[] */
protected $methods = [];
/** @var DocBlockProperty[] */
protected $properties = [];
/**
* Parse the given docblock
*
* The docblock can be of a method, class or property.
*
* @param ReflectionClass $reflector
*/
public function __construct(ReflectionClass $reflector)
{
parent::__construct($reflector);
}
/** @inheritdoc */
protected function getContext()
{
return $this->reflector->getName();
}
/**
* Get the public methods of this class
*
* @return DocBlockMethod[]
*/
public function getMethodDocs()
{
if ($this->methods) return $this->methods;
foreach ($this->reflector->getMethods() as $method) {
/** @var \ReflectionMethod $method */
if ($method->isConstructor()) continue;
if ($method->isDestructor()) continue;
if (!$method->isPublic()) continue;
$this->methods[$method->getName()] = new DocBlockMethod($method);
}
return $this->methods;
}
/**
* Get the public properties of this class
*
* @return DocBlockProperty[]
*/
public function getPropertyDocs()
{
if ($this->properties) return $this->properties;
foreach ($this->reflector->getProperties() as $property) {
/** @var \ReflectionProperty $property */
if (!$property->isPublic()) continue;
$this->properties[$property->getName()] = new DocBlockProperty($property);
}
return $this->properties;
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace dokuwiki\Remote\OpenApiDoc;
use ReflectionMethod;
class DocBlockMethod extends DocBlock
{
/**
* Parse the given docblock
*
* The docblock can be of a method, class or property.
*
* @param ReflectionMethod $reflector
*/
public function __construct(ReflectionMethod $reflector)
{
parent::__construct($reflector);
$this->refineParam();
$this->refineReturn();
}
/**
* Parse the param tag into its components
*
* @return void
*/
protected function refineParam()
{
$result = [];
// prefill from reflection
foreach ($this->reflector->getParameters() as $parameter) {
$refType = $parameter->getType();
$result[$parameter->getName()] = [
'type' => new Type($refType ? $refType->getName() : 'string', $this->getContext()),
'optional' => $parameter->isOptional(),
'description' => '',
];
if($parameter->isDefaultValueAvailable()) {
$result[$parameter->getName()]['default'] = $parameter->getDefaultValue();
}
}
// refine from doc tags
foreach ($this->tags['param'] ?? [] as $param) {
[$type, $name, $description] = array_map('trim', sexplode(' ', $param, 3, ''));
if ($name === '' || $name[0] !== '$') continue;
$name = substr($name, 1);
if (!isset($result[$name])) continue; // reflection says this param does not exist
$result[$name]['type'] = new Type($type, $this->getContext());
$result[$name]['description'] = $description;
}
$this->tags['param'] = $result;
}
/**
* Parse the return tag into its components
*
* @return void
*/
protected function refineReturn()
{
// prefill from reflection
$refType = $this->reflector->getReturnType();
$result = [
'type' => new Type($refType ? $refType->getName() : 'void', $this->getContext()),
'description' => '',
];
// refine from doc tag
foreach ($this->tags['return'] ?? [] as $return) {
[$type, $description] = array_map('trim', sexplode(' ', $return, 2, ''));
$result['type'] = new Type($type);
$result['description'] = $description;
}
$this->tags['return'] = $result;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace dokuwiki\Remote\OpenApiDoc;
class DocBlockProperty extends DocBlock
{
/** @var Type */
protected $type;
/**
* Parse the given docblock
*
* The docblock can be of a method, class or property.
*
* @param \ReflectionProperty $reflector
*/
public function __construct(\ReflectionProperty $reflector)
{
parent::__construct($reflector);
$this->refineVar();
}
/**
* The Type of this property
*
* @return Type
*/
public function getType()
{
return $this->type;
}
/**
* Parse the var tag into its components
*
* @return void
*/
protected function refineVar()
{
$refType = $this->reflector->getType();
$this->type = new Type($refType ? $refType->getName() : 'string', $this->getContext());
if (!isset($this->tags['var'])) return;
[$type, $description] = array_map('trim', sexplode(' ', $this->tags['var'][0], 2, ''));
$this->type = new Type($type, $this->getContext());
$this->summary = $description;
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace dokuwiki\Remote\OpenApiDoc;
class Type
{
protected $typehint;
protected $context;
/**
* @param string $typehint The typehint as read from the docblock
* @param string $context A fully qualified class name in which context the typehint is used
*/
public function __construct($typehint, $context = '')
{
$this->typehint = $typehint;
$this->context = $context;
}
/**
* Return a primitive PHP type
*
* @param string $typehint
* @return string
*/
protected function toPrimitiveType($typehint)
{
if (str_ends_with($typehint, '[]')) {
return 'array';
}
if (in_array($typehint, ['boolean', 'false', 'true'])) {
return 'bool';
}
if (in_array($typehint, ['integer', 'date'])) {
return 'int';
}
if ($typehint === 'file') {
return 'string';
}
// fully qualified class name
if ($typehint[0] === '\\') {
return ltrim($typehint, '\\');
}
// relative class name, try to resolve
if ($this->context && ctype_upper($typehint[0])) {
return ClassResolver::getInstance()->resolve($typehint, $this->context);
}
return $typehint;
}
/**
* Return a primitive type understood by the XMLRPC server
*
* @param string $typehint
* @return string
*/
public function getJSONRPCType()
{
return $this->toPrimitiveType($this->typehint);
}
/**
* If this is an array, return the type of the array elements
*
* @return Type|null null if this is not a typed array
*/
public function getSubType()
{
$type = $this->typehint;
if (!str_ends_with($type, '[]')) {
return null;
}
$type = substr($type, 0, -2);
return new Type($type, $this->context);
}
/**
* Return a type understood by the XMLRPC server
*
* @return string
*/
public function getXMLRPCType()
{
$type = $this->typehint;
// keep custom types
if (in_array($type, ['date', 'file', 'struct'])) {
return $type;
}
$type = $this->toPrimitiveType($this->typehint);
// primitive types
if (in_array($type, ['int', 'string', 'double', 'bool', 'array'])) {
return $type;
}
// everything else is an object
return 'object'; //should this return 'struct'?
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace dokuwiki\Remote\Response;
/**
* These are simple data objects that hold the response data API calls
*
* They are transmitted as associative arrays automatically created by
* converting the object to an array
*/
abstract class ApiResponse
{
/**
* Initialize the response object with the given data
*
* Each response object has different properties and might get passed different data from
* various internal methods. The constructor should handle all of that and also fill up
* missing properties when needed.
*
* @param array $data
*/
abstract public function __construct($data);
}

View File

@ -0,0 +1,58 @@
<?php
namespace dokuwiki\Remote\Response;
/**
* Represents a single page revision in the wiki.
*/
class Page extends ApiResponse
{
/** @var string The page ID */
public $id;
/** @var int The page revision aka last modified timestamp */
public $revision;
/** @var int The page size in bytes */
public $size;
/** @var string The page title */
public $title;
/** @var int The current user's permissions for this page */
public $perms;
/** @var string MD5 sum over the page's content (only if requested) */
public $hash;
/** @inheritdoc */
public function __construct($data)
{
$this->id = cleanID($data['id'] ?? '');
if ($this->id === '') {
throw new \InvalidArgumentException('Missing id');
}
$this->revision = (int)($data['rev'] ?? $data['lastModified'] ?? @filemtime(wikiFN($this->id)));
$this->size = (int)($data['size'] ?? @filesize(wikiFN($this->id)));
$this->title = $data['title'] ?? $this->retrieveTitle();
$this->perms = $data['perm'] ?? auth_quickaclcheck($this->id);
$this->hash = $data['hash'] ?? '';
}
/**
* Get the title for the page
*
* Honors $conf['useheading']
*
* @return string
*/
protected function retrieveTitle()
{
global $conf;
if ($conf['useheading']) {
$title = p_get_first_heading($this->id);
if ($title) {
return $title;
}
}
return $this->id;
}
}