File modules/cms/action/OpenApiGenerator.class.php

Last commit: Sat Oct 17 02:16:28 2026 +0200	Jan Dankert	Refactoring: Removed empty view() and post() methods; OpenAPI cleanup, removed these unused paths.
1 <?php 2 3 namespace cms\action; 4 5 header('Content-Type: text/plain'); 6 7 /** 8 * Using the Component classes and generating a OpenAPI-File. 9 */ 10 error_reporting(E_ALL); 11 ini_set('display_errors', 1); 12 ini_set('display_startup_errors', 1); 13 14 use cms\api\APIResponse; 15 use cms\base\Startup; 16 17 #Startup::initialize(); 18 19 use ReflectionNamedType; 20 use ReflectionUnionType; 21 use util\ClassUtils; 22 use util\FileUtils; 23 use util\json\JSON; 24 use util\Session; 25 use util\YAML; 26 use cms\action\UiInClass; 27 28 class OpenApiGenerator 29 { 30 private $paths = []; 31 32 public function __construct() 33 { 34 foreach( FileUtils::readDir(__DIR__) as $file ) { 35 if ( substr($file, -16, 16) == 'Action.class.php' ) { 36 $this->analyzeAction($file); 37 } 38 } 39 40 $this->createOpenAPISpecification(); 41 } 42 43 44 private function analyzeAction($file) 45 { 46 $actionName = strtolower(substr($file, 0, strlen($file)-16)); 47 $actionClassName = substr($file, 0, strlen($file)-10); 48 $actionClass = new \ReflectionClass('cms\action\\'.$actionClassName); 49 if ( $actionClass->isAbstract() ) { 50 echo("Action class $actionName is abstract\n"); 51 return; 52 } 53 echo("Analyzing $actionName ...\n"); 54 $this->analyzeMethod($actionName); 55 } 56 57 58 private function analyzeMethod($action) 59 { 60 $responseName = ClassUtils::getSimple( APIResponse::class ); 61 62 if ( ! is_dir(__DIR__.'/'.$action.'/')) 63 return; 64 foreach( FileUtils::readDir(__DIR__.'/'.$action.'/') as $file ) { 65 if ( substr($file, -16, 16) == 'Action.class.php' ) { 66 $actionName = strtolower(substr($file, strlen($action), strlen($file)-16-strlen($action))); 67 $actionClassName = substr($file, 0, strlen($file)-10); 68 69 echo("Analyzing $action => $file ...\n"); 70 $actionClass = new \ReflectionClass('cms\action\\'.$action.'\\'.$actionClassName); 71 72 $viewMethod = $actionClass->getMethod('view'); 73 74 if ( $viewMethod->getDeclaringClass()->getName() == $actionClass->getName() ) { 75 76 } 77 $attrs = $viewMethod->getAttributes(UiOutClass::class); 78 if ( sizeof($attrs)>0) { 79 80 $viewOut = [ 81 '$ref'=>'#/components/schemas/'.substr(strrchr($attrs[0]->newInstance()->className, '\\'), 1) 82 ]; 83 }else{ 84 $viewOut = [ 85 'type' => 'object', 86 'additionalProperties' => true, 87 ]; 88 } 89 90 91 $postMethod = $actionClass->getMethod('post'); 92 $attrs = $postMethod->getAttributes(UiInClass::class); 93 if ( sizeof($attrs)>0) { 94 95 $attrs[0]->newInstance(); 96 $postIn = [ 97 '$ref'=>'#/components/schemas/'.substr(strrchr($attrs[0]->newInstance()->className, '\\'), 1) 98 ]; 99 }else{ 100 $postIn = [ 101 'type' => 'object', 102 'additionalProperties' => true, 103 ]; 104 } 105 //if ( !$actionClass->hasMethod('view') ) 106 // throw new ClientException("no method view in $action => $file ..."); 107 $path = [ 108 'summary' => $this->getSummary($actionClass->getDocComment()), 109 'description' => $this->getDescription($actionClass->getDocComment()) 110 ]; 111 112 if ( $viewMethod->getDeclaringClass()->getName() == $actionClass->getName() ) 113 $path['get'] = [ 114 'responses'=>[ 115 '2XX'=>[ 116 'description'=> $this->clearDocComment($viewMethod->getDocComment()), 117 'content'=>['application/json'=>[ 118 'schema'=>$viewOut 119 , 120 ]], 121 ], 122 'default'=>[ 123 'description'=> 'Client error', 124 'content'=>['application/json'=>[ 125 'schema'=>['$ref'=>"#/components/schemas/".$responseName], 126 ]], 127 ], 128 ], 129 'operationId'=>'get'.ucfirst($action).ucfirst($actionName), 130 #'summary' =>$this->getSummary ($actionClass->getMethod('view')->getDocComment()), 131 'description'=>$this->clearDocComment($viewMethod->getDocComment()), 132 'parameters' =>$this->getParameters ($viewMethod), 133 'tags' =>[ucfirst($action)], 134 135 ]; 136 137 if ( $postMethod->getDeclaringClass()->getName() == $actionClass->getName() ) 138 139 $path['post'] = [ 140 'requestBody' => [ 141 'description' => '', 142 'required' => true, // Gibt an, ob der Body zwingend mitgeschickt werden muss 143 'content' => [ 144 'application/json' => [ 145 'schema' => $postIn 146 ] 147 ] 148 ], 149 'responses'=>[ 150 'default'=>[ 151 'description'=> 'API response', 152 'content'=>['application/json'=>[ 153 'schema'=>['$ref'=>"#/components/schemas/".$responseName] 154 ] 155 ] 156 ], 157 ], 158 'operationId'=>'save'.ucfirst($action).ucfirst($actionName), 159 'summary' =>$this->getSummary ($postMethod->getDocComment()), 160 'description'=>$this->getDescription($postMethod->getDocComment()), 161 'parameters' =>$this->getParameters ($postMethod), 162 'tags' =>[ucfirst($action)], 163 ]; 164 165 $this->paths['/'.$action.'/{id}/'.$actionName] = $path; 166 } 167 } 168 169 } 170 171 172 173 private function analyzeComponents(): array 174 { 175 $schemas = []; 176 177 if ( ! is_dir(__DIR__.'/../api/')) 178 return []; 179 foreach( FileUtils::readDir(__DIR__.'/../api/') as $file ) { 180 if ( substr($file, -10, 10) == '.class.php' ) { 181 $schema = []; 182 $schema['type'] = 'object'; 183 $properties = []; 184 $className = substr($file, 0, strlen($file)-10); 185 186 echo("Analyzing component in $file ...\n"); 187 $class = new \ReflectionClass('cms\api\\'.$className); 188 $schema['description'] = $this->clearDocComment($class->getDocComment()); 189 190 foreach( $class->getProperties() as $property ) { 191 $name = $property->getName(); 192 // 1. Prüfen, ob überhaupt ein Typ deklariert ist (z.B. public $data; hat keinen) 193 if (!$property->hasType()) { 194 $properties[$name] = ['type' => 'string', 'description' => 'Untyped property']; // Fallback 195 } 196 else{ 197 198 // 2. Umgang mit Union Types (z.B. string|int) – OpenAPI nutzt hier meist 'oneOf' 199 $type = $property->getType(); 200 if ($type instanceof ReflectionUnionType) { 201 $subTypes = []; 202 foreach ($type->getTypes() as $subType) { 203 $subTypes[] = $this->mapSingleType($subType); 204 } 205 $properties[$name] = ['oneOf' => $subTypes] + ['description' => $this->clearDocComment($property->getDocComment())]; 206 } 207 // 3. Normaler, einzelner Typ (ReflectionNamedType) 208 elseif ($type instanceof ReflectionNamedType) { 209 $properties[$name] = $this->mapSingleType($type) + ['description' => $this->clearDocComment($property->getDocComment()) ]; 210 } 211 } 212 213 } 214 $schema['properties'] = $properties; 215 216 217 $schemas[$className] = $schema; 218 } 219 } 220 return $schemas; 221 222 } 223 224 225 private function createOpenAPISpecification() 226 { 227 $spec = [ 228 "openapi" => "3.1.0", 229 'info' => $this->getInfo(), 230 'webhooks' => $this->getWebhooks(), 231 'servers'=> $this->getServers(), 232 'paths'=>$this->paths, 233 'components'=> [ 234 'securitySchemes'=> $this->getSecuritySchemes(), 235 'schemas'=>$this->analyzeComponents(), 236 ], 237 'security'=>[array_map( function () {return [];},$this->getSecuritySchemes() )], 238 ]; 239 240 $openapiYamlFile = @fopen(__DIR__ . '/../../../openapi.yaml', 'w'); 241 fwrite($openapiYamlFile, "# OpenAPI specification\n".YAML::dump($spec)); 242 $openapiJsonFile = @fopen(__DIR__ . '/../../../openapi.json', 'w'); 243 fwrite($openapiJsonFile, JSON::encode($spec)); 244 } 245 246 247 248 249 private function getSummary( $docComment ) 250 { 251 $docComment = ltrim($docComment,'/* '); 252 $docComment = str_replace(["\n","*"],['',''],$docComment); 253 $posFirstAt = strpos($docComment,'@'); 254 if ( $posFirstAt ) 255 $docComment = substr($docComment,0,$posFirstAt-1); 256 $posFirstDot = strpos($docComment,'.'); 257 if ( $posFirstDot ) 258 $docComment = substr($docComment,0,$posFirstDot); 259 return trim($docComment); 260 } 261 262 private function getDescription( $docComment ) 263 { 264 $docComment = ltrim($docComment,'/* '); 265 $docComment = str_replace(["\n","*"],['',''],$docComment); 266 $posFirstAt = strpos($docComment,'@'); 267 if ( $posFirstAt ) 268 $docComment = substr($docComment,0,$posFirstAt-1); 269 return trim($docComment); 270 } 271 272 private function getParameters( \ReflectionMethod $method ): array 273 { 274 $docComment = $method->getDocComment(); 275 276 $params = [ 0=>[ 277 'in'=>'path', 278 'name'=>'id', 279 'schema'=>['type'=>'integer'], 280 'required'=>true, 281 'description'=>'ID', 282 ] 283 ]; 284 while(true) { 285 $posApi = strpos($docComment,'@api'); 286 if ( $posApi ) { 287 $posEndApi = strpos($docComment,'@',$posApi+1); 288 $paramDef = substr($docComment,$posApi+4,$posEndApi-$posApi-4); 289 $paramParts = explode('|',$paramDef ); 290 $params[] = [ 291 'in' =>'query', 292 'name'=>str_replace('*','',@$paramParts[0]), 293 'schema'=>['type'=>@$paramParts[1]], 294 'required'=>strpos($paramParts[0],'*')!==false, 295 'description'=>trim(@$paramParts[2],"\n\r\t\v\0*/"), 296 ]; 297 $docComment = substr($docComment,$posEndApi-1); 298 } 299 else 300 break; 301 } 302 303 304 $attrs = $method->getAttributes(UiInParameter::class); 305 /** @var \ReflectionAttribute $attr */ 306 foreach ($attrs as $attr) { 307 308 $params[] = [ 309 'in' => 'query', 310 'name' => $attr->newInstance()->name, 311 'schema' => ['type' => 'string'], 312 'required' => true, 313 #'description' => '', 314 ]; 315 316 } 317 return $params; 318 } 319 320 321 function mapSingleType(ReflectionNamedType $namedType): array 322 { 323 $typeName = $namedType->getName(); 324 $isNullable = $namedType->allowsNull(); 325 326 // Mapping von PHP-Typen auf OpenAPI-Standardtypen 327 $mapping = [ 328 'int' => 'integer', 329 'float' => 'number', 330 'string' => 'string', 331 'bool' => 'boolean', 332 'array' => 'array', 333 'mixed' => 'string', // mixed wird im klassischen OpenAPI oft als string oder ohne typ deklariert 334 ]; 335 336 // Wenn es ein primitiver PHP-Typ ist 337 if (isset($mapping[$typeName])) { 338 $result = ['type' => $mapping[$typeName]]; 339 340 // Für Listen (Arrays) musst du im Generator später oft noch die 'items' bestimmen 341 if ($typeName === 'array') { 342 $result['items'] = ['type' => 'string']; // Standard-Fallback oder via DocBlock auflösen 343 } 344 345 if ($isNullable) { 346 $result['nullable'] = true; // Gültig für OpenAPI 3.0 347 } 348 return $result; 349 } 350 351 // Wenn der Typ kein primitiver Typ ist, ist es eine Klasse/ein Objekt 352 // Hier erzeugst du die Referenz auf deine Components 353 if (class_exists($typeName)) { 354 // Bereinigt den Namespace für OpenAPI (z.B. App\DTO\User -> App.DTO.User) 355 $componentName = str_replace('\\', '.', $typeName); 356 357 if ($isNullable) { 358 // Da $ref keine Geschwister-Keys (wie nullable) haben darf, verpacken wir es in allOf 359 return [ 360 'nullable' => true, 361 'allOf' => [ 362 ['$ref' => "#/components/schemas/{$componentName}"] 363 ] 364 ]; 365 } 366 367 return ['$ref' => "#/components/schemas/{$componentName}"]; 368 } 369 370 return ['type' => 'object']; 371 } 372 373 private function getInfo():array 374 { 375 return 376 [ 377 'title' => 'OpenRat Content Management System API', 378 'description' => 'OpenRat Content Management System', 379 'version' => '2.0.0', 380 'license' => [ 381 'name'=>'GPL v3', 382 'url' => 'https://www.gnu.org/licenses/gpl-3.0.de.html', 383 ], 384 'contact' => [ 385 'email' => 'kontakt-website@openrat.de', 386 'name' => 'OpenRat CMS API support', 387 ], 388 ]; 389 } 390 391 private function getSecuritySchemes():array 392 { 393 return [ 394 'BasicAuth'=>[ 395 'type' =>'http', 396 'scheme'=>'basic', 397 ], 398 'BearerAuth'=>[ 399 'type' =>'http', 400 'scheme'=>'bearer', 401 ], 402 'ApiKeyAuth'=>[ 403 'type'=>'apiKey', 404 'in' =>'header', 405 'name'=>'X-API-Key', 406 ], 407 'TokenAuth'=>[ 408 'type'=>'http', 409 'scheme' =>'token', 410 ], 411 ]; 412 } 413 414 private function getServers():array 415 { 416 return 417 [ 418 ['url'=> '//demo.openrat.de' ,'description'=>'OpenRat Demo'], 419 ['url'=> '//localhost:8000/rest','description'=>'OpenRat local development'], 420 ['url'=> '//localhost:8090' ,'description'=>'OpenRat local development'], 421 ]; 422 } 423 424 425 function clearDocComment(string|bool $comment):string { 426 427 if ( !$comment ) return ''; 428 429 $comment = str_replace('/*','',$comment); 430 $comment = str_replace('*/','',$comment); 431 $comment = str_replace('*','',$comment); 432 $comment = str_replace("\n",' ',$comment); 433 434 $idx = strpos($comment,'@'); 435 if ( $idx !== false ) 436 $comment = substr( $comment,0,$idx-1); 437 438 return trim($comment); 439 } 440 441 private function getWebhooks(): array 442 { 443 return [ 444 'userCreated' => [ // Der Name des Events / Webhooks 445 'post' => [ 446 'summary' => 'Wird ausgelöst, wenn ein neuer Benutzer registriert wird', 447 'description' => 'Dein Server sendet dieses Event an die konfigurierte Webhook-URL des Clients.', 448 449 // Hier definierst du, was dein System an den Client SENDET 450 'requestBody' => [ 451 'required' => true, 452 'content' => [ 453 'application/json' => [ 454 'schema' => [ 455 // HIER verweist du ganz normal auf deine Component! 456 '$ref' => '#/components/schemas/Event' 457 ] 458 ] 459 ] 460 ], 461 462 // Hier definierst du, welche Antwort dein System vom Client ERWARTET 463 'responses' => [ 464 '200' => [ 465 'description' => 'Client hat das Event erfolgreich verarbeitet und bestätigt.' 466 ] 467 ] 468 ] 469 ] 470 ]; 471 } 472 473 }
Download modules/cms/action/OpenApiGenerator.class.php
History Sat, 17 Oct 2026 02:16:28 +0200 Jan Dankert Refactoring: Removed empty view() and post() methods; OpenAPI cleanup, removed these unused paths. Sat, 17 Oct 2026 01:37:53 +0200 Jan Dankert Refactoring: The TemplateCompile and XSDGenerator are now classes.