OpenAPI Gen: handle Object to Schema transformation correctly

All the basic mechanisms should now be in place to clean up the API
This commit is contained in:
Andreas Gohr 2024-01-04 13:29:22 +01:00
parent 53c2a557e0
commit dd7472d3f8
5 changed files with 178 additions and 365 deletions

View File

@ -3,6 +3,8 @@
namespace dokuwiki\Remote;
use dokuwiki\Remote\OpenApiDoc\DocBlockMethod;
class ApiCall
{
/** @var callable The method to be called for this endpoint */
@ -11,29 +13,13 @@ class ApiCall
/** @var bool Whether this call can be called without authentication */
protected bool $isPublic = false;
/** @var array Metadata on the accepted parameters */
protected array $args = [];
/** @var array Metadata on the return value */
protected array $return = [
'type' => 'string',
'description' => '',
];
/** @var string The summary of the method */
protected string $summary = '';
/** @var string The description of the method */
protected string $description = '';
/** @var array[] The parsed tags */
protected $tags;
/** @var DocBlockMethod The meta data of this call as parsed from its doc block */
protected $docs;
/**
* Make the given method available as an API call
*
* @param string|array $method Either [object,'method'] or 'function'
* @throws \ReflectionException
*/
public function __construct($method)
{
@ -42,7 +28,6 @@ class ApiCall
}
$this->method = $method;
$this->parseData();
}
/**
@ -61,6 +46,32 @@ class ApiCall
return call_user_func_array($this->method, $args);
}
/**
* Access the method documentation
*
* This lazy loads the docs only when needed
*
* @return DocBlockMethod
*/
public function getDocs()
{
if ($this->docs === null) {
try {
if (is_array($this->method)) {
$reflect = new \ReflectionMethod($this->method[0], $this->method[1]);
} else {
$reflect = new \ReflectionFunction($this->method);
}
$this->docs = new DocBlockMethod($reflect);
} catch (\ReflectionException $e) {
throw new \RuntimeException('Failed to parse API method documentation', 0, $e);
}
}
return $this->docs;
}
/**
* @return bool
*/
@ -85,41 +96,7 @@ class ApiCall
*/
public function getArgs(): array
{
return $this->args;
}
/**
* Limit the arguments to the given ones
*
* @param string[] $args
* @return $this
*/
public function limitArgs($args): self
{
foreach ($args as $arg) {
if (!isset($this->args[$arg])) {
throw new \InvalidArgumentException("Unknown argument $arg");
}
}
$this->args = array_intersect_key($this->args, array_flip($args));
return $this;
}
/**
* Set the description for an argument
*
* @param string $arg
* @param string $description
* @return $this
*/
public function setArgDescription(string $arg, string $description): self
{
if (!isset($this->args[$arg])) {
throw new \InvalidArgumentException('Unknown argument');
}
$this->args[$arg]['description'] = $description;
return $this;
return $this->getDocs()->getParameters();
}
/**
@ -127,19 +104,7 @@ class ApiCall
*/
public function getReturn(): array
{
return $this->return;
}
/**
* Set the description for the return value
*
* @param string $description
* @return $this
*/
public function setReturnDescription(string $description): self
{
$this->return['description'] = $description;
return $this;
return $this->getDocs()->getReturn();
}
/**
@ -147,17 +112,7 @@ class ApiCall
*/
public function getSummary(): string
{
return $this->summary;
}
/**
* @param string $summary
* @return $this
*/
public function setSummary(string $summary): self
{
$this->summary = $summary;
return $this;
return $this->getDocs()->getSummary();
}
/**
@ -165,203 +120,7 @@ class ApiCall
*/
public function getDescription(): string
{
return $this->description;
}
/**
* @param string $description
* @return $this
*/
public function setDescription(string $description): self
{
$this->description = $description;
return $this;
}
/**
* Returns the docblock tags that have not been processed specially
*
* @return array[]
*/
public function getTags()
{
return $this->tags;
}
/**
* Returns any data that is available in the given docblock tag
*
* @param string $tag
* @return string[] returns an empty array if no such tags exists
*/
public function getTag($tag)
{
if(isset($this->tags[$tag])) {
return $this->tags[$tag];
}
return [];
}
/**
* Fill in the metadata
*
* This uses Reflection to inspect the method signature and doc block
*
* @throws \ReflectionException
*/
protected function parseData()
{
if (is_array($this->method)) {
$reflect = new \ReflectionMethod($this->method[0], $this->method[1]);
} else {
$reflect = new \ReflectionFunction($this->method);
}
$docInfo = $this->parseDocBlock($reflect->getDocComment());
$this->summary = $docInfo['summary'];
$this->description = $docInfo['description'];
$this->tags = $docInfo['tags'];
foreach ($reflect->getParameters() as $parameter) {
$name = $parameter->name;
$realType = $parameter->getType();
if ($realType) {
$type = $realType->getName();
} elseif (isset($docInfo['args'][$name]['type'])) {
$type = $docInfo['args'][$name]['type'];
} else {
$type = 'string';
}
if (isset($docInfo['args'][$name]['description'])) {
$description = $docInfo['args'][$name]['description'];
} else {
$description = '';
}
$this->args[$name] = [
'type' => $type,
'description' => trim($description),
];
}
$returnType = $reflect->getReturnType();
if ($returnType) {
$this->return['type'] = $returnType->getName();
} elseif (isset($docInfo['return']['type'])) {
$this->return['type'] = $docInfo['return']['type'];
} else {
$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'];
}
}
/**
* Parse a doc block
*
* @param string $doc
* @return array
*/
protected function parseDocBlock($doc)
{
// strip asterisks and leading spaces
$doc = preg_replace(
['/^[ \t]*\/\*+[ \t]*/m', '/[ \t]*\*+[ \t]*/m', '/\*+\/\s*$/m', '/\s*\/\s*$/m'],
['', '', '', ''],
$doc
);
$doc = trim($doc);
// get all tags
$tags = [];
if (preg_match_all('/^@(\w+)\s+(.*)$/m', $doc, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$tags[$match[1]][] = trim($match[2]);
}
}
$params = $this->extractDocTags($tags);
// strip the tags from the doc
$doc = preg_replace('/^@(\w+)\s+(.*)$/m', '', $doc);
[$summary, $description] = sexplode("\n\n", $doc, 2, '');
return array_merge(
[
'summary' => trim($summary),
'description' => trim($description),
'tags' => $tags,
],
$params
);
}
/**
* Process the param and return tags
*
* @param array $tags
* @return array
*/
protected function extractDocTags(&$tags)
{
$result = [];
if (isset($tags['param'])) {
foreach ($tags['param'] as $param) {
[$type, $name, $description] = array_map('trim', sexplode(' ', $param, 3, ''));
if ($name[0] !== '$') continue;
$name = substr($name, 1);
$result['args'][$name] = [
'type' => $this->cleanTypeHint($type),
'description' => $description,
];
}
unset($tags['param']);
}
if (isset($tags['return'])) {
$return = $tags['return'][0];
[$type, $description] = array_map('trim', sexplode(' ', $return, 2, ''));
$result['return'] = [
'type' => $this->cleanTypeHint($type),
'response' => $type, // uncleaned
'description' => $description
];
unset($tags['return']);
}
return $result;
}
/**
* Matches the given type hint against the valid options for the remote API
*
* @param string $hint
* @return string
*/
protected function cleanTypeHint($hint)
{
$types = explode('|', $hint);
foreach ($types as $t) {
if (str_ends_with($t, '[]')) {
return 'array';
}
if ($t === 'boolean' || $t === 'true' || $t === 'false') {
return 'bool';
}
if (in_array($t, ['array', 'string', 'int', 'double', 'bool', 'null', 'date', 'file'])) {
return $t;
}
}
return 'string';
return $this->getDocs()->getDescription();
}
/**
@ -375,7 +134,7 @@ class ApiCall
{
$args = [];
foreach (array_keys($this->args) as $arg) {
foreach (array_keys($this->docs->getParameters()) as $arg) {
if (isset($params[$arg])) {
$args[] = $params[$arg];
} else {

View File

@ -52,21 +52,21 @@ class ApiCore
'dokuwiki.appendPage' => new ApiCall([$this, 'appendPage']),
'dokuwiki.createUser' => new ApiCall([$this, 'createUser']),
'dokuwiki.deleteUsers' => new ApiCall([$this, 'deleteUsers']),
'wiki.getPage' => (new ApiCall([$this, 'rawPage']))
->limitArgs(['page']),
'wiki.getPageVersion' => (new ApiCall([$this, 'rawPage']))
->setSummary('Get a specific revision of a wiki page'),
'wiki.getPageHTML' => (new ApiCall([$this, 'htmlPage']))
->limitArgs(['page']),
'wiki.getPageHTMLVersion' => (new ApiCall([$this, 'htmlPage']))
->setSummary('Get the HTML for a specific revision of a wiki page'),
// 'wiki.getPage' => (new ApiCall([$this, 'rawPage']))
// ->limitArgs(['page']),
// 'wiki.getPageVersion' => (new ApiCall([$this, 'rawPage']))
// ->setSummary('Get a specific revision of a wiki page'),
// 'wiki.getPageHTML' => (new ApiCall([$this, 'htmlPage']))
// ->limitArgs(['page']),
// 'wiki.getPageHTMLVersion' => (new ApiCall([$this, 'htmlPage']))
// ->setSummary('Get the HTML for a specific revision of a wiki page'),
'wiki.getAllPages' => new ApiCall([$this, 'listPages']),
'wiki.getAttachments' => new ApiCall([$this, 'listAttachments']),
'wiki.getBackLinks' => new ApiCall([$this, 'listBackLinks']),
'wiki.getPageInfo' => (new ApiCall([$this, 'pageInfo']))
->limitArgs(['page']),
'wiki.getPageInfoVersion' => (new ApiCall([$this, 'pageInfo']))
->setSummary('Get some basic data about a specific revison of a wiki page'),
// 'wiki.getPageInfo' => (new ApiCall([$this, 'pageInfo']))
// ->limitArgs(['page']),
// 'wiki.getPageInfoVersion' => (new ApiCall([$this, 'pageInfo']))
// ->setSummary('Get some basic data about a specific revison of a wiki page'),
'wiki.getPageVersions' => new ApiCall([$this, 'pageVersions']),
'wiki.putPage' => new ApiCall([$this, 'putPage']),
'wiki.listLinks' => new ApiCall([$this, 'listLinks']),
@ -198,7 +198,7 @@ class ApiCore
*
* This uses the search index and only returns pages that have been indexed already
*
* @return array[] A list of all pages with id, perms, size, lastModified
* @return Page[] A list of all pages with id, perms, size, lastModified
*/
public function listPages()
{

View File

@ -3,6 +3,8 @@
namespace dokuwiki\Remote;
use dokuwiki\Remote\OpenApiDoc\DocBlockClass;
use dokuwiki\Remote\OpenApiDoc\Type;
use dokuwiki\Utf8\PhpString;
class OpenAPIGenerator
@ -78,45 +80,10 @@ 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']);
$retExample = $this->generateExample('result', $retType);
$description = $call->getDescription();
$links = $call->getTag('link');
$links = $call->getDocs()->getTag('link');
if ($links) {
$description .= "\n\n**See also:**";
foreach ($links as $link) {
@ -124,6 +91,15 @@ class OpenAPIGenerator
}
}
$retType = $call->getReturn()['type'];
$result = array_merge(
[
'description' => $call->getReturn()['description'],
'examples' => [$this->generateExample('result', $retType->getOpenApiType())],
],
$this->typeToSchema($retType)
);
return [
'operationId' => $method,
'summary' => $call->getSummary(),
@ -142,11 +118,7 @@ class OpenAPIGenerator
'schema' => [
'type' => 'object',
'properties' => [
'result' => [
'type' => $retType,
'description' => $call->getReturn()['description'],
'examples' => [$retExample],
],
'result' => $result,
'error' => [
'type' => 'object',
'description' => 'Error object in case of an error',
@ -189,37 +161,18 @@ class OpenAPIGenerator
];
foreach ($args as $name => $info) {
$type = $this->fixTypes($info['type']);
$example = $this->generateExample($name, $type);
$props[$name] = [
'type' => $type,
'description' => $info['description'],
'examples' => [$example],
];
$example = $this->generateExample($name, $info['type']->getOpenApiType());
$props[$name] = array_merge(
[
'description' => $info['description'],
'examples' => [$example],
],
$this->typeToSchema($info['type'])
);
}
return $schema;
}
protected function fixTypes($type)
{
switch ($type) {
case 'int':
$type = 'integer';
break;
case 'bool':
$type = 'boolean';
break;
case 'file':
$type = 'string';
break;
}
return $type;
}
protected function generateExample($name, $type)
{
switch ($type) {
@ -257,4 +210,46 @@ class OpenAPIGenerator
return $url;
}
}
/**
* Generate the OpenAPI schema for the given type
*
* @param Type $type
* @return array
* @todo add example generation here
*/
public function typeToSchema(Type $type)
{
$schema = [
'type' => $type->getOpenApiType(),
];
// if a sub type is known, define the items
if($schema['type'] === 'array' && $type->getSubType()) {
$schema['items'] = $this->typeToSchema($type->getSubType());
}
// if this is an object, define the properties
if($schema['type'] === 'object') {
try {
$baseType = $type->getBaseType();
$doc = new DocBlockClass(new \ReflectionClass($baseType));
$schema['properties'] = [];
foreach ($doc->getPropertyDocs() as $property => $propertyDoc) {
$schema['properties'][$property] = array_merge(
[
'description' => $propertyDoc->getSummary(),
],
$this->typeToSchema($propertyDoc->getType())
);
}
} catch (\ReflectionException $e) {
// The class is not available, so we cannot generate a schema
}
}
return $schema;
}
}

View File

@ -2,6 +2,7 @@
namespace dokuwiki\Remote\OpenApiDoc;
use ReflectionFunction;
use ReflectionMethod;
class DocBlockMethod extends DocBlock
@ -12,16 +13,42 @@ class DocBlockMethod extends DocBlock
*
* The docblock can be of a method, class or property.
*
* @param ReflectionMethod $reflector
* @param ReflectionMethod|ReflectionFunction $reflector
*/
public function __construct(ReflectionMethod $reflector)
public function __construct($reflector)
{
parent::__construct($reflector);
$this->refineParam();
$this->refineReturn();
}
protected function getContext()
{
if($this->reflector instanceof ReflectionFunction) {
return null;
}
return parent::getContext();
}
/**
* Convenience method to access the method parameters
*
* @return array
*/
public function getParameters()
{
return $this->getTag('param');
}
/**
* Convenience method to access the method return
*
* @return array
*/
public function getReturn()
{
return $this->getTag('return');
}
/**
* Parse the param tag into its components
@ -77,7 +104,7 @@ class DocBlockMethod extends DocBlock
// refine from doc tag
foreach ($this->tags['return'] ?? [] as $return) {
[$type, $description] = array_map('trim', sexplode(' ', $return, 2, ''));
$result['type'] = new Type($type);
$result['type'] = new Type($type, $this->getContext());
$result['description'] = $description;
}

View File

@ -28,13 +28,16 @@ class Type
}
/**
* Return a primitive PHP type
* Return the base type
*
* This is the type this variable is. Eg. a string[] is an array.
*
* @param string $typehint
* @return string
*/
protected function toPrimitiveType($typehint)
public function getBaseType()
{
$typehint = $this->typehint;
if (str_ends_with($typehint, '[]')) {
return 'array';
}
@ -72,9 +75,38 @@ class Type
*/
public function getJSONRPCType()
{
return $this->toPrimitiveType($this->typehint);
return $this->getBaseType();
}
/**
* Get the base type as one of the supported OpenAPI types
*
* Formats (eg. int32 or double) are not supported
*
* @link https://swagger.io/docs/specification/data-models/data-types/
* @return string
*/
public function getOpenApiType()
{
switch ($this->getBaseType()) {
case 'int':
return 'integer';
case 'bool':
return 'boolean';
case 'array':
return 'array';
case 'string':
case 'mixed':
return 'string';
case 'double':
case 'float':
return 'number';
default:
return 'object';
}
}
/**
* If this is an array, return the type of the array elements
*
@ -104,7 +136,7 @@ class Type
return $type;
}
$type = $this->toPrimitiveType($this->typehint);
$type = $this->getBaseType($this->typehint);
// primitive types
if (in_array($type, ['int', 'string', 'double', 'bool', 'array'])) {