import 'dart:convert'; import 'dart:io'; import 'package:path/path.dart' as p; import 'package:spec_templates/method_parameter.dart'; import 'package:spec_templates/openapi_spec.dart'; import 'package:xml/xml.dart'; Future main(final List args) async { final tmpDirectory = Directory(p.join(Directory.systemTemp.path, 'nextcloud-neon')); if (!tmpDirectory.existsSync()) { tmpDirectory.createSync(); } final path = args[0]; final isCore = args[1] == 'true'; final appDirectory = Directory(p.absolute(p.normalize(path))); if (!appDirectory.existsSync()) { throw Exception('App directory $appDirectory not found'); } late String infoXmlPath; if (isCore) { infoXmlPath = p.join( 'specs', 'templates', 'appinfo_core.xml', ); } else { infoXmlPath = p.join( appDirectory.path, 'appinfo', 'info.xml', ); } final document = XmlDocument.parse(File(infoXmlPath).readAsStringSync()); final info = document.findElements('info').toList().single; final id = info.findElements('id').toList().single.innerText; final name = info.findElements('name').toList().single.innerText; final summary = info.findElements('summary').toList().single.innerText; final version = info.findElements('version').toList().single.innerText; final license = info.findElements('licence').toList().single.innerText; late String routesPhpPath; if (isCore) { routesPhpPath = p.join( appDirectory.path, 'routes.php', ); } else { routesPhpPath = p.join( appDirectory.path, 'appinfo', 'routes.php', ); } final routes = await _parseRoutesFile(tmpDirectory, routesPhpPath); final paths = {}; final hasRoutes = routes.keys.contains('routes'); final hasOCS = routes.keys.contains('ocs'); if (!hasRoutes && !hasOCS) { throw Exception('One of ocs and routes is required, but only found: "${routes.keys.join('", "')}"'); } final routesBasePath = '${isCore ? '' : '/apps'}/$id'; final ocsBasePath = '/ocs/v1.php$routesBasePath'; for (final k in routes.keys) { for (final route in routes[k]!) { final name = route['name'] as String; var url = route['url'] as String; // ignore: avoid_dynamic_calls final requirements = route['requirements']?.cast() as Map?; if (!url.startsWith('/')) { url = '/$url'; } if (url.endsWith('/')) { url = url.substring(0, url.length - 1); } if (k == 'routes') { url = '$routesBasePath$url'; } else if (k == 'ocs') { url = '$ocsBasePath$url'; } final verb = route['verb'] as String? ?? 'GET'; if (name.startsWith('page#') || name.startsWith('admin#')) { continue; } if (verb == 'GET' && url == '/') { continue; } final methodName = _getMethodName(name.split('#')[1]); final controllerName = _getControllerName(name.split('#')[0]); late String controllerFilePath; if (isCore) { controllerFilePath = p.join( appDirectory.path, 'Controller', '$controllerName.php', ); } else { controllerFilePath = p.join( appDirectory.path, 'lib', 'Controller', '$controllerName.php', ); } final controllerContent = File(controllerFilePath).readAsStringSync().replaceAll('\n', ''); if (methodName == 'preflightedCors') { continue; } final reg = RegExp('\\/\\*\\*((?:(?!\\/\\*\\*).)*?)\\*\\/(?:(?!\\*\\/).)*?public function $methodName\\(([^\\)]*)\\)'); final match = reg.allMatches(controllerContent).single; final docParameters = []; var current = ''; for (final docLine in match .group(1)! .split('*') .map((final s) { var r = s.trim(); while (r.contains(' ')) { r = r.replaceAll(' ', ' '); } return r; }) .where((final s) => s.isNotEmpty) .toList()) { if (docLine.startsWith('@')) { if (current != '') { docParameters.add(current); } } if (docLine.startsWith('@return')) { current = ''; break; } if (docLine.startsWith('@param')) { current = docLine; } else if (current != '') { current += ' $docLine'; } } if (current != '') { docParameters.add(current); } final methodParameters = _getMethodParameters( controllerName, methodName, match.group(2)!.split(',').map((final s) => s.trim()).where((final s) => s.isNotEmpty).toList(), docParameters, ); final parameterNames = RegExp('{[^}]*}').allMatches(url).map((final m) { final t = m.group(0)!; return t.substring(1, t.length - 1); }).toList(); final parameters = []; for (final parameterName in parameterNames) { MethodParameter? parameter; for (final methodParameter in methodParameters) { if (methodParameter.name == parameterName) { parameter = methodParameter; break; } } if (parameter == null && (requirements == null || requirements[parameterName] == null)) { throw Exception('Could not find parameter for $parameterName in $name'); } parameters.add( Parameter( name: parameterName, in_: 'path', required: true, description: parameter?.description, schema: { 'type': parameter?.openAPIType ?? 'TODO', if (parameter?.defaultValue != null) ...{ 'default': parameter?.defaultValue, }, }, ), ); } final queryParameters = []; for (final methodParameter in methodParameters) { var found = false; for (final parameter in parameters) { if (parameter.name == methodParameter.name) { found = true; break; } } if (!found) { queryParameters.add(methodParameter); } } if (paths[url] == null) { paths[url] = Path( parameters: parameters, ); } final operation = Operation( operationID: '${name.replaceAll('#', '-').toLowerCase()}-TODO', tags: [id], parameters: queryParameters.isNotEmpty ? queryParameters .map( (final queryParameter) => Parameter( name: queryParameter.name, in_: 'query', description: queryParameter.description, required: !queryParameter.nullable && queryParameter.defaultValue == null, schema: queryParameter.openAPIType == 'boolean' ? { // This is a quirk in Nextcloud where sending literal booleans in query parameters doesn't work and only integers work. // See https://github.com/nextcloud/server/issues/34226 'type': 'integer', if (queryParameter.defaultValue != null) ...{ 'default': queryParameter.defaultValue == 'true' ? 1 : 0, }, } : { 'type': queryParameter.openAPIType ?? 'TODO', if (queryParameter.defaultValue != null) ...{ 'default': queryParameter.defaultValue, }, }, ), ) .toList() : null, responses: { 200: Response( description: '', content: { 'application/json': MediaType( schema: { 'type': 'string', }, ), }, ), }, ); switch (verb) { case 'DELETE': paths[url]!.delete = operation; break; case 'GET': paths[url]!.get = operation; break; case 'POST': paths[url]!.post = operation; break; case 'PUT': paths[url]!.put = operation; break; case 'PATCH': paths[url]!.patch = operation; break; case 'OPTIONS': paths[url]!.options = operation; break; default: throw Exception('Unsupported verb: $verb'); } } } late String spdxIdentifier; switch (license) { case 'agpl': spdxIdentifier = ' AGPL-3.0'; break; default: throw Exception('Can not convert license name "$license" to a SPDX identifier'); } File( p.join( 'specs', 'templates', '$id.json', ), ).writeAsStringSync( const JsonEncoder.withIndent(' ').convert( Spec( version: '3.1.0', info: Info( title: name, version: version, description: summary, license: License( name: license, identifier: spdxIdentifier, ), ), tags: [id], paths: paths, ).toMap(), ), ); } String _getControllerName(final String name) { final result = StringBuffer(); final parts = name.split(''); for (var i = 0; i < parts.length; i++) { var char = parts[i]; final prevChar = i > 0 ? parts[i - 1] : null; if (char == '_') { continue; } if (i == 0 || prevChar == '_') { char = char.toUpperCase(); } result.write(char); } result.write('Controller'); return result.toString(); } String _getMethodName(final String name) { final result = StringBuffer(); final parts = name.split(''); for (var i = 0; i < parts.length; i++) { var char = parts[i]; final prevChar = i > 0 ? parts[i - 1] : null; if (char == '_') { continue; } if (prevChar == '_') { char = char.toUpperCase(); } result.write(char); } return result.toString(); } List _getMethodParameters( final String controllerName, final String methodName, final List parameters, final List docs, ) { var reg = RegExp(r'@param ((?:[a-z|\[\]]+ )?)(\$?)([a-zA-Z_]+)((?: .*)?)'); final docMatches = []; for (final doc in docs) { reg.allMatches(doc).forEach(docMatches.add); } final result = []; reg = RegExp(r'(\??)((?:[a-z-A-Z]+ )?)\$([a-zA-Z_]+)((?: = .*)?)'); for (final parameter in parameters) { final match = reg.allMatches(parameter).single; var nullable = match.group(1)!.isNotEmpty; String? type = match.group(2)!.trim(); if (type.isEmpty) { type = null; } final name = match.group(3)!; final defaultValue = match.group(4)!.replaceAll('=', '').trim(); String? description; for (final doc in docMatches) { final docName = doc.group(3)!.trim(); if (docName == name) { final docType = doc.group(1)!.trim(); final docDescription = doc.group(4)!.trim(); if (docDescription.isNotEmpty) { description = docDescription; } if (type == null && docType.isNotEmpty) { final parts = docType.split('|').where((final p) => p.isNotEmpty); if (parts.contains('null')) { nullable = true; } final nonNullableParts = parts.where((final p) => p != 'null'); if (nonNullableParts.length > 1) { if (nonNullableParts.contains('string')) { // Catch all type = 'string'; continue; } throw Exception( 'Can not determine reliable type for "$docType" for parameter "$name" of method "$methodName" in controller "$controllerName"', ); } else { type = nonNullableParts.single; } } } } result.add( MethodParameter( type: type, nullable: nullable, name: name, defaultValue: defaultValue.isNotEmpty ? defaultValue : null, description: description, controllerName: controllerName, methodName: methodName, ), ); } return result; } Future>>> _parseRoutesFile( final Directory tmpDirectory, final String path, ) async { final content = File(path).readAsStringSync(); late String routes; if (content.contains('registerRoutes')) { routes = RegExp(r'registerRoutes\(\$this, (\[[^;]*)\);').firstMatch(content)!.group(1)!; } else if (content.contains('return [')) { routes = RegExp(r'return (\[[^;]*);').firstMatch(content)!.group(1)!; } else if (content.contains('return array_merge_recursive(')) { final includes = RegExp(r"include\(__DIR__ . '/([^']*)'\),") .allMatches(RegExp(r'return array_merge_recursive\(\n(.*)\n\);', dotAll: true).firstMatch(content)!.group(1)!) .map((final match) => match.group(1)!) .toList(); final out = >>{}; for (final include in includes) { final routes = await _parseRoutesFile(tmpDirectory, p.join(File(path).parent.path, include)); for (final key in routes.keys) { if (!out.containsKey(key)) { out[key] = []; } out[key]!.addAll(routes[key]!); } } return out; } else { throw Exception('Unsupported routes format'); } final allowedVariables = [ 'requirements', 'requirementsWithToken', 'requirementsWithMessageId', ]; final variables = RegExp('^(\\\$(${allowedVariables.join('|')}) =[^;]*;)\$', multiLine: true) .allMatches(content) .map((final match) => match.group(1)!) .toList(); final phpFile = File(p.join(tmpDirectory.path, p.basename(path))); final jsonFile = File(p.join(tmpDirectory.path, p.basename(path).replaceAll('.php', '.json'))); phpFile.writeAsStringSync( ''' ''', ); final result = await Process.run('php', [phpFile.path]); if (result.exitCode != 0) { throw Exception('Failed to run php: ${result.stderr}'); } return (json.decode(jsonFile.readAsStringSync()) as Map).map( (final key, final value) => MapEntry>>( key, (value as List).map((final a) => a as Map).toList(), ), ); }