From 2bcbddf96bd34caa4fd19ec4fb1100147e0f731b Mon Sep 17 00:00:00 2001 From: Vasiliy Ditsyak Date: Tue, 24 Feb 2026 20:21:45 +0100 Subject: [PATCH] performance fix --- .../parser/corrector/open_api_corrector.dart | 395 ++++++++++-------- .../corrector/corrector_performance_test.dart | 91 ++++ 2 files changed, 312 insertions(+), 174 deletions(-) create mode 100644 swagger_parser/test/corrector/corrector_performance_test.dart diff --git a/swagger_parser/lib/src/parser/corrector/open_api_corrector.dart b/swagger_parser/lib/src/parser/corrector/open_api_corrector.dart index 3bf439f7..754c6552 100644 --- a/swagger_parser/lib/src/parser/corrector/open_api_corrector.dart +++ b/swagger_parser/lib/src/parser/corrector/open_api_corrector.dart @@ -31,209 +31,256 @@ class OpenApiCorrector { final models = schemes ?? definitions; if (models != null) { - // BUGFIX: Protect properties blocks and API path definitions by detecting their range and replacing with placeholders - final blocks = - <({int start, int end, String placeholder, String original})>[]; - - // Detect lines starting with 'properties:' and their indentation level - final propertiesPattern = RegExp( - r'^([ \t]*)(properties:)\s*$', - multiLine: true, - ); - - // Detect API path definition lines (e.g., " /api/v1/app/user_point_balance:") - final pathPattern = RegExp( - r'^([ \t]*)(/[^\s:]+:)\s*$', - multiLine: true, - ); - - for (final match in propertiesPattern.allMatches(fileContent)) { - final indent = match[1]!; - final indentLength = indent.length; - final matchStart = match.start; - final matchEnd = match.end; - - // Find the block end by detecting the next key with same or shallower indentation - var blockEnd = matchEnd; - final lines = fileContent.substring(matchEnd).split('\n'); - - for (var i = 1; i < lines.length; i++) { - final line = lines[i]; - - // Skip empty lines - if (line.trim().isEmpty) { - blockEnd += line.length + 1; // +1 for \n - continue; - } + // Pre-compute type corrections map once + final typeCorrections = {}; + for (final type in models.keys) { + var correctType = type; + for (final rule in config.replacementRules) { + correctType = rule.apply(correctType)!; + } + correctType = correctType.toPascal; + if (correctType != type) { + typeCorrections[type] = correctType; + } + } - // Check indentation level - final lineIndent = line.length - line.trimLeft().length; + // If no corrections needed, skip the expensive block processing + if (typeCorrections.isNotEmpty) { + fileContent = _applyCorrections(fileContent, models, typeCorrections); + } + } - // Block ends when a key with same or shallower indentation is found - if (lineIndent <= indentLength && line.trimLeft().isNotEmpty) { - break; - } + return config.isJson + ? json.decode(fileContent) as Map + : (loadYaml(fileContent) as YamlMap).toMap(); + } + String _applyCorrections( + String fileContent, + Map models, + Map typeCorrections, + ) { + // BUGFIX: Protect properties blocks and API path definitions by detecting + // their range and replacing with placeholders + final blocks = + <({int start, int end, String placeholder, String original})>[]; + + // Detect lines starting with 'properties:' and their indentation level + final propertiesPattern = RegExp( + r'^([ \t]*)(properties:)\s*$', + multiLine: true, + ); + + // Detect API path definition lines (e.g., " /api/v1/app/user_point_balance:") + final pathPattern = RegExp( + r'^([ \t]*)(/[^\s:]+:)\s*$', + multiLine: true, + ); + + for (final match in propertiesPattern.allMatches(fileContent)) { + final indent = match[1]!; + final indentLength = indent.length; + final matchStart = match.start; + final matchEnd = match.end; + + // Find the block end by detecting the next key with same or shallower + // indentation + var blockEnd = matchEnd; + final lines = fileContent.substring(matchEnd).split('\n'); + + for (var i = 1; i < lines.length; i++) { + final line = lines[i]; + + // Skip empty lines + if (line.trim().isEmpty) { blockEnd += line.length + 1; // +1 for \n + continue; } - // Get the entire properties block - final originalBlock = fileContent.substring(matchStart, blockEnd); + // Check indentation level + final lineIndent = line.length - line.trimLeft().length; - // Generate placeholder - final placeholder = '${indent}___PROPERTIES_BLOCK_${blocks.length}___'; + // Block ends when a key with same or shallower indentation is found + if (lineIndent <= indentLength && line.trimLeft().isNotEmpty) { + break; + } - blocks.add(( - start: matchStart, - end: blockEnd, - placeholder: placeholder, - original: originalBlock, - )); + blockEnd += line.length + 1; // +1 for \n } - // Detect and protect API path definitions (e.g., " /api/v1/app/user_point_balance:") - for (final match in pathPattern.allMatches(fileContent)) { - final indent = match[1]!; - final matchStart = match.start; - final matchEnd = match.end; + // Get the entire properties block + final originalBlock = fileContent.substring(matchStart, blockEnd); - // API path definitions are single lines, so we just protect the entire line - final originalPath = fileContent.substring(matchStart, matchEnd); + // Generate placeholder + final placeholder = '${indent}___PROPERTIES_BLOCK_${blocks.length}___'; - // Generate placeholder - final placeholder = '${indent}___PATH_DEFINITION_${blocks.length}___'; - - blocks.add(( - start: matchStart, - end: matchEnd, - placeholder: placeholder, - original: originalPath, - )); - } - - // Sort blocks by start position - blocks.sort((a, b) => a.start.compareTo(b.start)); - - // Check for duplicate blocks and exclude them - final validBlocks = - <({int start, int end, String placeholder, String original})>[]; - for (var i = 0; i < blocks.length; i++) { - final block = blocks[i]; - var isValid = true; - - // Check if this block is completely contained within another block - for (var j = 0; j < blocks.length; j++) { - if (i != j) { - final other = blocks[j]; - // Skip if block is completely contained within other - if (block.start >= other.start && block.end <= other.end) { - isValid = false; - break; - } - } - } + blocks.add(( + start: matchStart, + end: blockEnd, + placeholder: placeholder, + original: originalBlock, + )); + } - if (isValid) { - validBlocks.add(block); - } - } + // Detect and protect API path definitions + for (final match in pathPattern.allMatches(fileContent)) { + final indent = match[1]!; + final matchStart = match.start; + final matchEnd = match.end; - // Reconstruct string from back to front - var result = ''; - var lastEnd = fileContent.length; + // API path definitions are single lines + final originalPath = fileContent.substring(matchStart, matchEnd); - for (var i = validBlocks.length - 1; i >= 0; i--) { - final block = validBlocks[i]; + // Generate placeholder + final placeholder = '${indent}___PATH_DEFINITION_${blocks.length}___'; - // Add the part after the block - result = fileContent.substring(block.end, lastEnd) + result; + blocks.add(( + start: matchStart, + end: matchEnd, + placeholder: placeholder, + original: originalPath, + )); + } - // Add placeholder - result = block.placeholder + result; + // Sort blocks by start position + blocks.sort((a, b) => a.start.compareTo(b.start)); + + // Check for duplicate blocks and exclude them + final validBlocks = + <({int start, int end, String placeholder, String original})>[]; + for (var i = 0; i < blocks.length; i++) { + final block = blocks[i]; + var isValid = true; + + // Check if this block is completely contained within another block + for (var j = 0; j < blocks.length; j++) { + if (i != j) { + final other = blocks[j]; + // Skip if block is completely contained within other + if (block.start >= other.start && block.end <= other.end) { + isValid = false; + break; + } + } + } - lastEnd = block.start; + if (isValid) { + validBlocks.add(block); } + } - // Add the first part - result = fileContent.substring(0, lastEnd) + result; + // Reconstruct string with placeholders using StringBuffer + final placeholderBuf = StringBuffer(); + var lastEnd = 0; + // Build a map from placeholder to block for fast lookup during restore + final placeholderToBlock = + {}; + + for (final block in validBlocks) { + placeholderBuf.write(fileContent.substring(lastEnd, block.start)); + placeholderBuf.write(block.placeholder); + placeholderToBlock[block.placeholder] = block; + lastEnd = block.end; + } + placeholderBuf.write(fileContent.substring(lastEnd)); + fileContent = placeholderBuf.toString(); + + // Apply replacement rules to all class names and format to PascalCase + // (properties blocks are already replaced with placeholders) + for (final entry in typeCorrections.entries) { + final type = entry.key; + final correctType = entry.value; + + // Escape all special characters for regular expressions + final escapedType = type.replaceAllMapped( + RegExp(r'[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]'), + (m) => + // Add a backslash before each special character + '\\${m[0]}', + ); - fileContent = result; + // Replace schema names (properties blocks are already replaced with + // placeholders) + final replacementPattern = RegExp('[ "\'/]$escapedType[ "\':]'); - // Apply replacement rules to all class names and format to PascalCase - for (final type in models.keys) { - var correctType = type; + fileContent = fileContent.replaceAllMapped( + replacementPattern, + (match) => match[0]!.replaceAll(type, correctType), + ); + } - for (final rule in config.replacementRules) { - correctType = rule.apply(correctType)!; - } + // Build a single combined regex for all type corrections to use in + // properties blocks. This replaces the O(blocks * models) nested loop + // with O(blocks) single-regex passes. + final refReplacementPattern = _buildCombinedRefPattern(typeCorrections); + + // Restore blocks from placeholders in a single pass + // Convert $ref schema names within properties blocks while preserving + // property names. API path definitions are restored without conversion. + final resultBuf = StringBuffer(); + var searchStart = 0; + + for (final block in validBlocks) { + final placeholder = block.placeholder; + final idx = fileContent.indexOf(placeholder, searchStart); + if (idx == -1) continue; + + // Write everything before the placeholder + resultBuf.write(fileContent.substring(searchStart, idx)); + + var restoredBlock = block.original; + + // Only convert $ref in properties blocks, not in API path definitions + if (!placeholder.contains('___PATH_DEFINITION_') && + refReplacementPattern != null) { + restoredBlock = restoredBlock.replaceAllMapped( + refReplacementPattern, + (match) { + final fullMatch = match[0]!; + // Extract the schema name from the $ref path + // The captured group is the schema name + final schemaName = match[1]!; + final correctType = typeCorrections[schemaName]; + if (correctType != null) { + return fullMatch.replaceAll(schemaName, correctType); + } + return fullMatch; + }, + ); + } - correctType = correctType.toPascal; + resultBuf.write(restoredBlock); + searchStart = idx + placeholder.length; + } - if (correctType != type) { - // Escape all special characters for regular expressions - final escapedType = type.replaceAllMapped( - RegExp(r'[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]'), - (m) => - // Add a backslash before each special character - '\\${m[0]}', - ); - - // Replace schema names (properties blocks are already replaced with placeholders) - final replacementPattern = RegExp('[ "\'/]$escapedType[ "\':]'); - - fileContent = fileContent.replaceAllMapped( - replacementPattern, - (match) => match[0]!.replaceAll(type, correctType), - ); - } - } + // Write remaining content after the last placeholder + resultBuf.write(fileContent.substring(searchStart)); - // Restore blocks from placeholders - // Convert $ref schema names within properties blocks while preserving property names - // API path definitions are restored without any conversion - for (final block in validBlocks) { - final placeholder = block.placeholder; - var restoredBlock = block.original; - - // Only convert $ref in properties blocks, not in API path definitions - if (!placeholder.contains('___PATH_DEFINITION___')) { - // Convert schema names in $ref within the block - // Uses same pattern as original code (handles 'schemes' typo as well) - for (final type in models.keys) { - var correctType = type; - - for (final rule in config.replacementRules) { - correctType = rule.apply(correctType)!; - } + return resultBuf.toString(); + } - correctType = correctType.toPascal; - - if (correctType != type) { - // Escape all special characters for regular expressions - final escapedType = type.replaceAllMapped( - RegExp(r'[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]'), - (m) => '\\${m[0]}', - ); - - // In properties blocks, convert $ref values and - // discriminator mapping values (not property names) - restoredBlock = restoredBlock.replaceAllMapped( - RegExp( - '(?:\\\$ref:|\\w+):\\s*[\'"]#/[^\'"]*/$escapedType[\'"]', - ), - (match) => match[0]!.replaceAll(type, correctType), - ); - } - } - } + /// Build a single regex that matches any $ref or discriminator mapping value + /// containing any of the types that need correction. + RegExp? _buildCombinedRefPattern(Map typeCorrections) { + if (typeCorrections.isEmpty) return null; + + // Escape each type for use in regex, sort by length descending so longer + // names match first (prevents partial matches) + final escapedTypes = typeCorrections.keys.map((type) { + return type.replaceAllMapped( + RegExp(r'[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]'), + (m) => '\\${m[0]}', + ); + }).toList() + ..sort((a, b) => b.length.compareTo(a.length)); - fileContent = fileContent.replaceAll(placeholder, restoredBlock); - } - } + final alternation = escapedTypes.join('|'); - return config.isJson - ? json.decode(fileContent) as Map - : (loadYaml(fileContent) as YamlMap).toMap(); + // Match $ref or discriminator mapping values that reference any of the + // types. Captures the schema name in group 1. + return RegExp( + '(?:\\\$ref:|\\w+):\\s*[\'"]#/[^\'"]*/(${alternation})[\'"]', + ); } } diff --git a/swagger_parser/test/corrector/corrector_performance_test.dart b/swagger_parser/test/corrector/corrector_performance_test.dart new file mode 100644 index 00000000..714c7508 --- /dev/null +++ b/swagger_parser/test/corrector/corrector_performance_test.dart @@ -0,0 +1,91 @@ +import 'dart:convert' show json; + +import 'package:swagger_parser/src/parser/corrector/open_api_corrector.dart'; +import 'package:swagger_parser/swagger_parser.dart'; +import 'package:test/test.dart'; + +/// Generates a large OpenAPI YAML string with [schemaCount] schemas, +/// each having [propertiesPerSchema] properties with $ref references. +String _generateLargeSchema({ + required int schemaCount, + required int propertiesPerSchema, +}) { + final buf = StringBuffer() + ..writeln('openapi: 3.0.0') + ..writeln('info:') + ..writeln(' title: Performance Test API') + ..writeln(' version: 1.0.0') + ..writeln('paths:') + ..writeln(' /test:') + ..writeln(' get:') + ..writeln(' operationId: getTest') + ..writeln(' responses:') + ..writeln(" '200':") + ..writeln(' description: OK') + ..writeln(' content:') + ..writeln(' application/json:') + ..writeln(' schema:') + ..writeln(" \$ref: '#/components/schemas/Schema0'") + ..writeln('components:') + ..writeln(' schemas:'); + + for (var i = 0; i < schemaCount; i++) { + buf + ..writeln(' Schema$i:') + ..writeln(' type: object') + ..writeln(' properties:'); + + for (var j = 0; j < propertiesPerSchema; j++) { + final refIndex = (i + j + 1) % schemaCount; + buf + ..writeln(' field_$j:') + ..writeln(" \$ref: '#/components/schemas/Schema$refIndex'"); + } + } + + return buf.toString(); +} + +void main() { + group('OpenApiCorrector performance', () { + test( + 'corrector with replacement rules completes in reasonable time ' + 'for large schemas', + () { + final schemaCount = 200; + final schema = _generateLargeSchema( + schemaCount: schemaCount, + propertiesPerSchema: 5, + ); + + final config = ParserConfig( + schema, + isJson: false, + replacementRules: [ + ReplacementRule(pattern: RegExp(r'$'), replacement: 'Dto'), + ], + ); + + final corrector = OpenApiCorrector(config); + + final sw = Stopwatch()..start(); + final result = corrector.correct(); + final elapsed = sw.elapsedMilliseconds; + + // Must complete in under 30 seconds (generous limit). + // Before the fix this would take 7+ minutes on a similar sized schema. + expect(elapsed, lessThan(5000), + reason: 'Corrector took ${elapsed}ms, expected < 30s'); + + // Verify corrections were actually applied + final schemas = (result['components'] + as Map)['schemas'] as Map; + expect(schemas.length, schemaCount); + + // Verify that schema names and $ref values contain corrected names + final resultJson = json.encode(result); + expect(resultJson, contains('Schema0Dto')); + }, + ); + }); +}