Release 0.2.0
Complete OpenAPI 3.0/3.1 generation, diagnostics, lifecycle safety, enum and naming mappings, bilingual documentation, examples, and pub.dev release validation.
This commit is contained in:
@@ -1,6 +1,28 @@
|
||||
/// Generates Retrofit and Freezed Dart/Flutter clients from OpenAPI documents.
|
||||
library;
|
||||
|
||||
export 'src/config/config.dart';
|
||||
export 'src/ir/ir.dart';
|
||||
export 'src/koi_swagger_parser.dart';
|
||||
export 'src/preset/preset.dart';
|
||||
export 'src/config/config.dart'
|
||||
show
|
||||
ApiGenerationConfig,
|
||||
BinaryResponseMode,
|
||||
BuildRunnerMode,
|
||||
ClientConfig,
|
||||
DartTypeMapping,
|
||||
EnumValueMapping,
|
||||
FormattingConfig,
|
||||
GenerationConfig,
|
||||
GenerationMappingsConfig,
|
||||
InputConfig,
|
||||
JsonSerializableConfig,
|
||||
KoiSwaggerConfig,
|
||||
ModelGenerationConfig,
|
||||
OutputConfig,
|
||||
ResponseMetadataMode,
|
||||
ResponseWrapperConfig,
|
||||
ResponseWrapperMode;
|
||||
export 'src/diagnostic/diagnostic.dart'
|
||||
show DiagnosticSeverity, KoiDiagnostic, KoiDiagnosticException;
|
||||
export 'src/ir/ir.dart' show GenerationResult;
|
||||
export 'src/koi_swagger_parser.dart' show KoiSwaggerParser;
|
||||
export 'src/preset/preset.dart'
|
||||
show BinaryTransportStyle, GeneratorPreset, GeneratorPresetRegistry;
|
||||
|
||||
+1493
-32
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
/// The impact of a parser or generator diagnostic.
|
||||
enum DiagnosticSeverity {
|
||||
/// Informational context that does not require corrective action.
|
||||
info,
|
||||
|
||||
/// A recoverable issue that may require user attention.
|
||||
warning,
|
||||
|
||||
/// An issue that prevents the requested operation from completing.
|
||||
error,
|
||||
}
|
||||
|
||||
/// A structured issue reported while loading, parsing, or generating code.
|
||||
class KoiDiagnostic {
|
||||
/// Creates a diagnostic with an optional source and logical location.
|
||||
const KoiDiagnostic({
|
||||
required this.code,
|
||||
required this.severity,
|
||||
required this.message,
|
||||
this.source,
|
||||
this.location,
|
||||
});
|
||||
|
||||
/// A stable machine-readable identifier for this issue.
|
||||
final String code;
|
||||
|
||||
/// The impact of this issue.
|
||||
final DiagnosticSeverity severity;
|
||||
|
||||
/// A human-readable English explanation of this issue.
|
||||
final String message;
|
||||
|
||||
/// The source document or configuration file associated with this issue.
|
||||
final String? source;
|
||||
|
||||
/// A JSON path, reference, or other logical location within [source].
|
||||
final String? location;
|
||||
|
||||
/// Formats this diagnostic as one human-readable line.
|
||||
String format() {
|
||||
final context = <String>[
|
||||
if (source != null && source!.isNotEmpty) source!,
|
||||
if (location != null && location!.isNotEmpty) location!,
|
||||
].join(' ');
|
||||
final suffix = context.isEmpty ? '' : ' ($context)';
|
||||
return '[${severity.name}] $code: $message$suffix';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => format();
|
||||
}
|
||||
|
||||
/// Collects diagnostics while a multi-stage operation is in progress.
|
||||
///
|
||||
/// This implementation utility is intentionally not exported from the
|
||||
/// package's primary library.
|
||||
class KoiDiagnosticBag {
|
||||
final List<KoiDiagnostic> _diagnostics = <KoiDiagnostic>[];
|
||||
|
||||
/// An immutable snapshot of all diagnostics added so far.
|
||||
List<KoiDiagnostic> get diagnostics =>
|
||||
List<KoiDiagnostic>.unmodifiable(_diagnostics);
|
||||
|
||||
/// Whether at least one error diagnostic has been added.
|
||||
bool get hasErrors => _diagnostics.any(
|
||||
(diagnostic) => diagnostic.severity == DiagnosticSeverity.error,
|
||||
);
|
||||
|
||||
/// Adds [diagnostic] to this bag.
|
||||
void add(KoiDiagnostic diagnostic) {
|
||||
_diagnostics.add(diagnostic);
|
||||
}
|
||||
|
||||
/// Adds an informational diagnostic.
|
||||
void info(String code, String message, {String? source, String? location}) {
|
||||
add(
|
||||
KoiDiagnostic(
|
||||
code: code,
|
||||
severity: DiagnosticSeverity.info,
|
||||
message: message,
|
||||
source: source,
|
||||
location: location,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds a warning diagnostic.
|
||||
void warning(
|
||||
String code,
|
||||
String message, {
|
||||
String? source,
|
||||
String? location,
|
||||
}) {
|
||||
add(
|
||||
KoiDiagnostic(
|
||||
code: code,
|
||||
severity: DiagnosticSeverity.warning,
|
||||
message: message,
|
||||
source: source,
|
||||
location: location,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds an error diagnostic.
|
||||
void error(String code, String message, {String? source, String? location}) {
|
||||
add(
|
||||
KoiDiagnostic(
|
||||
code: code,
|
||||
severity: DiagnosticSeverity.error,
|
||||
message: message,
|
||||
source: source,
|
||||
location: location,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Throws [KoiDiagnosticException] when this bag contains any errors.
|
||||
void throwIfErrors({
|
||||
String message = 'OpenAPI pre-generation validation failed.',
|
||||
}) {
|
||||
if (hasErrors) {
|
||||
throw KoiDiagnosticException(message, diagnostics);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An exception that preserves the structured diagnostics for a failed run.
|
||||
class KoiDiagnosticException implements Exception {
|
||||
/// Creates an exception with a summary [message] and related [diagnostics].
|
||||
const KoiDiagnosticException(this.message, this.diagnostics);
|
||||
|
||||
/// A concise summary of the failure.
|
||||
final String message;
|
||||
|
||||
/// The diagnostics collected before the operation failed.
|
||||
final List<KoiDiagnostic> diagnostics;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final details = diagnostics
|
||||
.where((diagnostic) => diagnostic.severity == DiagnosticSeverity.error)
|
||||
.map((diagnostic) => diagnostic.format())
|
||||
.join('\n');
|
||||
return details.isEmpty ? message : '$message\n$details';
|
||||
}
|
||||
}
|
||||
+1547
-49
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,686 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../config/config.dart';
|
||||
import '../diagnostic/diagnostic.dart';
|
||||
import '../ir/ir.dart';
|
||||
|
||||
/// Generator version stored in generated-file lifecycle manifests.
|
||||
const String koiSwaggerParserGeneratorVersion = '0.2.0';
|
||||
|
||||
/// Schema version used to validate generated-file lifecycle manifests.
|
||||
const int koiSwaggerParserManifestSchemaVersion = 1;
|
||||
|
||||
/// Describes files changed, removed, or planned by a lifecycle pass.
|
||||
class GenerationLifecycleOutcome {
|
||||
/// Creates an immutable lifecycle result.
|
||||
const GenerationLifecycleOutcome({
|
||||
required this.generatedFiles,
|
||||
required this.writtenFiles,
|
||||
required this.deletedFiles,
|
||||
required this.wouldWriteFiles,
|
||||
required this.wouldDeleteFiles,
|
||||
required this.skippedUnchanged,
|
||||
required this.manifestPath,
|
||||
this.diagnostics = const <KoiDiagnostic>[],
|
||||
});
|
||||
|
||||
/// Complete set of files owned by the current generated bundle.
|
||||
final List<String> generatedFiles;
|
||||
|
||||
/// Files written or updated by this lifecycle pass.
|
||||
final List<String> writtenFiles;
|
||||
|
||||
/// Stale generated files deleted by this lifecycle pass.
|
||||
final List<String> deletedFiles;
|
||||
|
||||
/// Files that a dry run would write or update.
|
||||
final List<String> wouldWriteFiles;
|
||||
|
||||
/// Stale files that a dry run would delete.
|
||||
final List<String> wouldDeleteFiles;
|
||||
|
||||
/// Whether unchanged inputs and outputs allowed all writes to be skipped.
|
||||
final bool skippedUnchanged;
|
||||
|
||||
/// Absolute path of the manifest controlling generated-file ownership.
|
||||
final String manifestPath;
|
||||
|
||||
/// Structured informational and warning diagnostics from the lifecycle pass.
|
||||
final List<KoiDiagnostic> diagnostics;
|
||||
}
|
||||
|
||||
/// Applies safe, manifest-backed writes and stale generated-file cleanup.
|
||||
class GenerationLifecycle {
|
||||
/// Creates a lifecycle manager configured by [config].
|
||||
const GenerationLifecycle(this.config);
|
||||
|
||||
/// Generation settings and managed output roots.
|
||||
final KoiSwaggerConfig config;
|
||||
|
||||
/// Applies [bundle], or calculates its changes when [dryRun] is `true`.
|
||||
///
|
||||
/// [postWrite] runs after primary files are written and before their final
|
||||
/// hashes are recorded, allowing formatting and build steps to participate
|
||||
/// in manifest validation.
|
||||
Future<GenerationLifecycleOutcome> apply({
|
||||
required GeneratedBundle bundle,
|
||||
required Map<String, dynamic> sourceDocument,
|
||||
required List<String> sources,
|
||||
required List<String> dependencies,
|
||||
required bool dryRun,
|
||||
required Future<void> Function(List<String> changedFiles) postWrite,
|
||||
}) async {
|
||||
final diagnostics = KoiDiagnosticBag();
|
||||
final files = _normalizeGeneratedFiles(bundle.files, diagnostics);
|
||||
diagnostics.throwIfErrors(
|
||||
message: 'Generated-file lifecycle validation failed.',
|
||||
);
|
||||
|
||||
final manifestFile = File(
|
||||
_absoluteConfiguredPath(config.output.manifestPath),
|
||||
);
|
||||
final previous = await _readManifest(manifestFile, diagnostics);
|
||||
final hashes = _LifecycleHashes(
|
||||
specHash: _hashCanonical(sourceDocument),
|
||||
configHash: _hashCanonical(_configFingerprint()),
|
||||
contentHash: _hashCanonical(<Object?>[
|
||||
for (final file in files)
|
||||
<String, Object?>{
|
||||
'path': _portablePath(file.path),
|
||||
'content': file.content,
|
||||
},
|
||||
]),
|
||||
);
|
||||
final desiredPaths = files.map((file) => file.path).toSet();
|
||||
final previousEntries = _manifestEntries(previous);
|
||||
final staleEntries = previousEntries
|
||||
.where((entry) => !desiredPaths.contains(entry.path))
|
||||
.toList(growable: false);
|
||||
|
||||
final canSkip =
|
||||
config.output.skipIfUnchanged &&
|
||||
_manifestHashesMatch(previous, hashes) &&
|
||||
await _manifestFilesMatch(previous, files);
|
||||
if (canSkip) {
|
||||
diagnostics.info(
|
||||
'lifecycle.unchanged_skipped',
|
||||
'The OpenAPI document, configuration, and generated files are '
|
||||
'unchanged; writing and post-processing were skipped.',
|
||||
source: manifestFile.path,
|
||||
);
|
||||
return GenerationLifecycleOutcome(
|
||||
generatedFiles: files.map((file) => file.path).toList(growable: false),
|
||||
writtenFiles: const <String>[],
|
||||
deletedFiles: const <String>[],
|
||||
wouldWriteFiles: const <String>[],
|
||||
wouldDeleteFiles: const <String>[],
|
||||
skippedUnchanged: true,
|
||||
manifestPath: manifestFile.path,
|
||||
diagnostics: diagnostics.diagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
final wouldWrite = <String>[
|
||||
for (final file in files)
|
||||
if (!await _fileHasContent(file.path, file.content)) file.path,
|
||||
];
|
||||
final wouldDelete = <String>[];
|
||||
if (config.output.cleanStaleFiles) {
|
||||
for (final entry in staleEntries) {
|
||||
if (await _canDeleteStale(entry, diagnostics, reportModified: false)) {
|
||||
wouldDelete.add(entry.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return GenerationLifecycleOutcome(
|
||||
generatedFiles: files.map((file) => file.path).toList(growable: false),
|
||||
writtenFiles: const <String>[],
|
||||
deletedFiles: const <String>[],
|
||||
wouldWriteFiles: wouldWrite,
|
||||
wouldDeleteFiles: wouldDelete,
|
||||
skippedUnchanged: false,
|
||||
manifestPath: manifestFile.path,
|
||||
diagnostics: diagnostics.diagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
await manifestFile.parent.create(recursive: true);
|
||||
final lockFile = File('${manifestFile.path}.lock');
|
||||
final lock = await lockFile.open(mode: FileMode.append);
|
||||
try {
|
||||
await lock.lock(FileLock.exclusive);
|
||||
// Another process may have completed while this process waited.
|
||||
final lockedPrevious = await _readManifest(manifestFile, diagnostics);
|
||||
if (config.output.skipIfUnchanged &&
|
||||
_manifestHashesMatch(lockedPrevious, hashes) &&
|
||||
await _manifestFilesMatch(lockedPrevious, files)) {
|
||||
diagnostics.info(
|
||||
'lifecycle.unchanged_skipped_after_lock',
|
||||
'Another process completed the same output while this process was '
|
||||
'waiting for the generation lock; duplicate writing was skipped.',
|
||||
source: manifestFile.path,
|
||||
);
|
||||
return GenerationLifecycleOutcome(
|
||||
generatedFiles: files
|
||||
.map((file) => file.path)
|
||||
.toList(growable: false),
|
||||
writtenFiles: const <String>[],
|
||||
deletedFiles: const <String>[],
|
||||
wouldWriteFiles: const <String>[],
|
||||
wouldDeleteFiles: const <String>[],
|
||||
skippedUnchanged: true,
|
||||
manifestPath: manifestFile.path,
|
||||
diagnostics: diagnostics.diagnostics,
|
||||
);
|
||||
}
|
||||
|
||||
final lockedEntries = _manifestEntries(lockedPrevious);
|
||||
final lockedStaleEntries = lockedEntries
|
||||
.where((entry) => !desiredPaths.contains(entry.path))
|
||||
.toList(growable: false);
|
||||
final writtenFiles = <String>[];
|
||||
for (final file in files) {
|
||||
if (await _fileHasContent(file.path, file.content)) {
|
||||
continue;
|
||||
}
|
||||
await _atomicWrite(File(file.path), file.content);
|
||||
writtenFiles.add(file.path);
|
||||
}
|
||||
|
||||
final deletedFiles = <String>[];
|
||||
final retainedStaleEntries = <_ManifestFileEntry>[];
|
||||
for (final entry in lockedStaleEntries) {
|
||||
if (!config.output.cleanStaleFiles) {
|
||||
retainedStaleEntries.add(entry);
|
||||
continue;
|
||||
}
|
||||
if (!await _canDeleteStale(entry, diagnostics)) {
|
||||
continue;
|
||||
}
|
||||
final staleFile = File(entry.path);
|
||||
if (await staleFile.exists()) {
|
||||
await staleFile.delete();
|
||||
deletedFiles.add(entry.path);
|
||||
await _deleteEmptyGeneratedParents(entry.path);
|
||||
}
|
||||
}
|
||||
|
||||
await postWrite(List<String>.unmodifiable(writtenFiles));
|
||||
|
||||
final manifestEntries = <Map<String, Object?>>[];
|
||||
for (final file in files) {
|
||||
final ioFile = File(file.path);
|
||||
if (!await ioFile.exists()) {
|
||||
diagnostics.error(
|
||||
'lifecycle.generated_file_missing',
|
||||
'A generated file is missing after post-processing.',
|
||||
source: file.path,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
manifestEntries.add(<String, Object?>{
|
||||
'path': _portablePath(file.path),
|
||||
'sha256': await _hashFile(ioFile),
|
||||
});
|
||||
}
|
||||
diagnostics.throwIfErrors(
|
||||
message: 'Post-generation file validation failed.',
|
||||
);
|
||||
|
||||
final manifest = <String, Object?>{
|
||||
'schemaVersion': koiSwaggerParserManifestSchemaVersion,
|
||||
'generator': <String, Object?>{
|
||||
'name': 'koi_swagger_parser',
|
||||
'version': koiSwaggerParserGeneratorVersion,
|
||||
},
|
||||
'specHash': hashes.specHash,
|
||||
'configHash': hashes.configHash,
|
||||
'contentHash': hashes.contentHash,
|
||||
'sources': sources,
|
||||
'dependencies': dependencies,
|
||||
'generatedFiles': manifestEntries,
|
||||
if (retainedStaleEntries.isNotEmpty)
|
||||
'retainedStaleFiles': <Map<String, Object?>>[
|
||||
for (final entry in retainedStaleEntries)
|
||||
<String, Object?>{
|
||||
'path': _portablePath(entry.path),
|
||||
'sha256': entry.sha256,
|
||||
},
|
||||
],
|
||||
};
|
||||
await _atomicWrite(
|
||||
manifestFile,
|
||||
'${const JsonEncoder.withIndent(' ').convert(manifest)}\n',
|
||||
);
|
||||
|
||||
return GenerationLifecycleOutcome(
|
||||
generatedFiles: files.map((file) => file.path).toList(growable: false),
|
||||
writtenFiles: List<String>.unmodifiable(writtenFiles),
|
||||
deletedFiles: List<String>.unmodifiable(deletedFiles),
|
||||
wouldWriteFiles: const <String>[],
|
||||
wouldDeleteFiles: const <String>[],
|
||||
skippedUnchanged: false,
|
||||
manifestPath: manifestFile.path,
|
||||
diagnostics: diagnostics.diagnostics,
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await lock.unlock();
|
||||
} on FileSystemException {
|
||||
// The descriptor may already be unlocked after an earlier I/O error.
|
||||
}
|
||||
await lock.close();
|
||||
}
|
||||
}
|
||||
|
||||
List<GeneratedFile> _normalizeGeneratedFiles(
|
||||
List<GeneratedFile> input,
|
||||
KoiDiagnosticBag diagnostics,
|
||||
) {
|
||||
final byPath = <String, GeneratedFile>{};
|
||||
for (final file in input) {
|
||||
final path = _absoluteConfiguredPath(file.path);
|
||||
if (!_isWithinOutputRoots(path)) {
|
||||
diagnostics.error(
|
||||
'lifecycle.output_outside_managed_roots',
|
||||
'A renderer attempted to write outside the configured '
|
||||
'api_dir/models_dir roots.',
|
||||
source: path,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
final previous = byPath[path];
|
||||
if (previous != null && previous.content != file.content) {
|
||||
diagnostics.error(
|
||||
'lifecycle.duplicate_output_path',
|
||||
'Multiple renderers generated different content for the same path.',
|
||||
source: path,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
byPath[path] = GeneratedFile(path: path, content: file.content);
|
||||
}
|
||||
return byPath.values.toList(growable: false)
|
||||
..sort((left, right) => left.path.compareTo(right.path));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _readManifest(
|
||||
File file,
|
||||
KoiDiagnosticBag diagnostics,
|
||||
) async {
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(await file.readAsString());
|
||||
if (decoded is! Map) {
|
||||
throw const FormatException('manifest root is not an object');
|
||||
}
|
||||
final manifest = decoded.cast<String, dynamic>();
|
||||
if (manifest['schemaVersion'] != koiSwaggerParserManifestSchemaVersion) {
|
||||
diagnostics.warning(
|
||||
'lifecycle.manifest_version_ignored',
|
||||
'The manifest schema version is incompatible. A full generation '
|
||||
'will run without trusting the old manifest for file deletion.',
|
||||
source: file.path,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return manifest;
|
||||
} on Object catch (error) {
|
||||
diagnostics.warning(
|
||||
'lifecycle.manifest_invalid',
|
||||
'Unable to read the generation manifest. A full generation will run: '
|
||||
'$error',
|
||||
source: file.path,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<_ManifestFileEntry> _manifestEntries(Map<String, dynamic>? manifest) {
|
||||
if (manifest == null) {
|
||||
return const <_ManifestFileEntry>[];
|
||||
}
|
||||
final entries = <_ManifestFileEntry>[];
|
||||
for (final key in const <String>['generatedFiles', 'retainedStaleFiles']) {
|
||||
final rawEntries = manifest[key];
|
||||
if (rawEntries is! List) {
|
||||
continue;
|
||||
}
|
||||
for (final rawEntry in rawEntries) {
|
||||
if (rawEntry is! Map) {
|
||||
continue;
|
||||
}
|
||||
final rawPath = rawEntry['path']?.toString();
|
||||
final hash = rawEntry['sha256']?.toString();
|
||||
if (rawPath == null ||
|
||||
rawPath.isEmpty ||
|
||||
hash == null ||
|
||||
hash.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
entries.add(
|
||||
_ManifestFileEntry(
|
||||
path: _absoluteManifestPath(rawPath),
|
||||
sha256: hash,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
bool _manifestHashesMatch(
|
||||
Map<String, dynamic>? manifest,
|
||||
_LifecycleHashes hashes,
|
||||
) {
|
||||
if (manifest == null) {
|
||||
return false;
|
||||
}
|
||||
final generator = manifest['generator'];
|
||||
return generator is Map &&
|
||||
generator['version'] == koiSwaggerParserGeneratorVersion &&
|
||||
manifest['specHash'] == hashes.specHash &&
|
||||
manifest['configHash'] == hashes.configHash &&
|
||||
manifest['contentHash'] == hashes.contentHash;
|
||||
}
|
||||
|
||||
Future<bool> _manifestFilesMatch(
|
||||
Map<String, dynamic>? manifest,
|
||||
List<GeneratedFile> files,
|
||||
) async {
|
||||
if (manifest == null) {
|
||||
return false;
|
||||
}
|
||||
final rawEntries = manifest['generatedFiles'];
|
||||
if (rawEntries is! List || rawEntries.length != files.length) {
|
||||
return false;
|
||||
}
|
||||
final entries = _manifestEntries(<String, dynamic>{
|
||||
'generatedFiles': rawEntries,
|
||||
});
|
||||
if (entries.length != files.length) {
|
||||
return false;
|
||||
}
|
||||
final expectedPaths = files.map((file) => file.path).toSet();
|
||||
if (!entries.every((entry) => expectedPaths.contains(entry.path))) {
|
||||
return false;
|
||||
}
|
||||
for (final entry in entries) {
|
||||
final file = File(entry.path);
|
||||
if (!await file.exists() || await _hashFile(file) != entry.sha256) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _canDeleteStale(
|
||||
_ManifestFileEntry entry,
|
||||
KoiDiagnosticBag diagnostics, {
|
||||
bool reportModified = true,
|
||||
}) async {
|
||||
final manifestPath = _absoluteConfiguredPath(config.output.manifestPath);
|
||||
if (p.equals(entry.path, manifestPath) ||
|
||||
p.equals(entry.path, '$manifestPath.lock')) {
|
||||
if (reportModified) {
|
||||
diagnostics.warning(
|
||||
'lifecycle.control_file_not_stale',
|
||||
'The old manifest lists a lifecycle control file as generated; '
|
||||
'deletion was refused.',
|
||||
source: entry.path,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!_isWithinOutputRoots(entry.path)) {
|
||||
if (reportModified) {
|
||||
diagnostics.warning(
|
||||
'lifecycle.stale_path_outside_roots',
|
||||
'A path in the old manifest is outside the currently managed output '
|
||||
'roots; deletion was refused.',
|
||||
source: entry.path,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final file = File(entry.path);
|
||||
if (!await file.exists()) {
|
||||
return false;
|
||||
}
|
||||
final currentHash = await _hashFile(file);
|
||||
if (currentHash != entry.sha256) {
|
||||
if (reportModified) {
|
||||
diagnostics.warning(
|
||||
'lifecycle.stale_file_modified',
|
||||
'A stale generated file was modified after the previous generation '
|
||||
'and was preserved to avoid data loss. Handle it manually.',
|
||||
source: entry.path,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _isWithinOutputRoots(String path) {
|
||||
final normalized = _absoluteConfiguredPath(path);
|
||||
return <String>[config.output.apiDir, config.output.modelsDir].any((root) {
|
||||
final absoluteRoot = _absoluteConfiguredPath(root);
|
||||
return p.equals(normalized, absoluteRoot) ||
|
||||
p.isWithin(absoluteRoot, normalized);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _deleteEmptyGeneratedParents(String filePath) async {
|
||||
final roots = <String>[
|
||||
_absoluteConfiguredPath(config.output.apiDir),
|
||||
_absoluteConfiguredPath(config.output.modelsDir),
|
||||
];
|
||||
var directory = Directory(p.dirname(filePath));
|
||||
while (true) {
|
||||
final path = _absoluteConfiguredPath(directory.path);
|
||||
final root = roots.where(
|
||||
(candidate) => p.equals(candidate, path) || p.isWithin(candidate, path),
|
||||
);
|
||||
if (root.isEmpty || roots.any((candidate) => p.equals(candidate, path))) {
|
||||
return;
|
||||
}
|
||||
if (!await directory.exists() || !await directory.list().isEmpty) {
|
||||
return;
|
||||
}
|
||||
await directory.delete();
|
||||
directory = directory.parent;
|
||||
}
|
||||
}
|
||||
|
||||
String _portablePath(String path) {
|
||||
final absolute = _absoluteConfiguredPath(path);
|
||||
final root = _absoluteConfiguredPath(config.workingDirectory);
|
||||
if (p.equals(absolute, root) || p.isWithin(root, absolute)) {
|
||||
return p.relative(absolute, from: root);
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
String _absoluteManifestPath(String path) {
|
||||
return _absoluteConfiguredPath(path);
|
||||
}
|
||||
|
||||
String _absoluteConfiguredPath(String path) {
|
||||
if (p.isAbsolute(path)) {
|
||||
return p.normalize(path);
|
||||
}
|
||||
return p.normalize(p.join(config.workingDirectory, path));
|
||||
}
|
||||
|
||||
Map<String, Object?> _configFingerprint() {
|
||||
final mappings = config.generation.mappings;
|
||||
return <String, Object?>{
|
||||
'output': <String, Object?>{
|
||||
'baseDir': p.normalize(config.output.baseDir),
|
||||
'apiDir': p.normalize(config.output.apiDir),
|
||||
'modelsDir': p.normalize(config.output.modelsDir),
|
||||
'includedTags': config.output.includedTags,
|
||||
'excludedTags': config.output.excludedTags,
|
||||
'includedPaths': config.output.includedPaths,
|
||||
'excludedPaths': config.output.excludedPaths,
|
||||
'includedOperations': config.output.includedOperations,
|
||||
'excludedOperations': config.output.excludedOperations,
|
||||
'includedModels': config.output.includedModels,
|
||||
'excludedModels': config.output.excludedModels,
|
||||
'splitByTags': config.output.splitByTags,
|
||||
'extractVersionFromUrl': config.output.extractVersionFromUrl,
|
||||
'cleanStaleFiles': config.output.cleanStaleFiles,
|
||||
},
|
||||
'generation': <String, Object?>{
|
||||
'preset': config.generation.preset.name,
|
||||
'api': <String, Object?>{
|
||||
'enabled': config.generation.api.enabled,
|
||||
'useRetrofit': config.generation.api.useRetrofit,
|
||||
'cancelTokenSupport': config.generation.api.cancelTokenSupport,
|
||||
'responseMetadataMode':
|
||||
config.generation.api.responseMetadataMode.name,
|
||||
'binaryResponseMode': config.generation.api.binaryResponseMode.name,
|
||||
'clientClassName': config.generation.api.client.className,
|
||||
'clientFileName': config.generation.api.client.fileName,
|
||||
'responseWrapperMode':
|
||||
config.generation.api.responseWrapper.mode.name,
|
||||
'successDataField':
|
||||
config.generation.api.responseWrapper.successDataField,
|
||||
'detectStandardEnvelope':
|
||||
config.generation.api.responseWrapper.detectStandardEnvelope,
|
||||
'externalImport':
|
||||
config.generation.api.responseWrapper.externalImport,
|
||||
'externalClassName':
|
||||
config.generation.api.responseWrapper.externalClassName,
|
||||
},
|
||||
'models': <String, Object?>{
|
||||
'enabled': config.generation.models.enabled,
|
||||
'classPrefix': config.generation.models.classPrefix,
|
||||
'defaultNonNullableListToEmpty':
|
||||
config.generation.models.defaultNonNullableListToEmpty,
|
||||
'enumUnknownFallback': config.generation.models.enumUnknownFallback,
|
||||
'enumMappings': <String, Object?>{
|
||||
for (final enumEntry
|
||||
in config.generation.models.enumMappings.entries)
|
||||
enumEntry.key: <String, Object?>{
|
||||
for (final valueEntry in enumEntry.value.entries)
|
||||
'${valueEntry.key.runtimeType}:${valueEntry.key}':
|
||||
<String, Object?>{
|
||||
'name': valueEntry.value.name,
|
||||
'description': valueEntry.value.description,
|
||||
},
|
||||
},
|
||||
},
|
||||
'jsonSerializable': <String, Object?>{
|
||||
'checked': config.generation.models.jsonSerializable.checked,
|
||||
'explicitToJson':
|
||||
config.generation.models.jsonSerializable.explicitToJson,
|
||||
'includeIfNull':
|
||||
config.generation.models.jsonSerializable.includeIfNull,
|
||||
},
|
||||
},
|
||||
'mappings': <String, Object?>{
|
||||
'models': mappings.models,
|
||||
'properties': mappings.properties,
|
||||
'parameters': mappings.parameters,
|
||||
'operations': mappings.operations,
|
||||
'schemas': <String, Object?>{
|
||||
for (final entry in mappings.schemas.entries)
|
||||
entry.key: <String, Object?>{
|
||||
'type': entry.value.dartType,
|
||||
'import': entry.value.import,
|
||||
},
|
||||
},
|
||||
'types': <String, Object?>{
|
||||
for (final entry in mappings.types.entries)
|
||||
entry.key: <String, Object?>{
|
||||
'type': entry.value.dartType,
|
||||
'import': entry.value.import,
|
||||
},
|
||||
},
|
||||
'imports': mappings.imports,
|
||||
},
|
||||
},
|
||||
'formatting': <String, Object?>{
|
||||
'runDartFormat': config.generation.formatting.runDartFormat,
|
||||
'runDartFix': config.generation.formatting.runDartFix,
|
||||
'runBuildRunner': config.generation.formatting.runBuildRunner,
|
||||
'buildRunnerMode': config.generation.formatting.buildRunnerMode.name,
|
||||
'deleteConflictingOutputs':
|
||||
config.generation.formatting.deleteConflictingOutputs,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _LifecycleHashes {
|
||||
const _LifecycleHashes({
|
||||
required this.specHash,
|
||||
required this.configHash,
|
||||
required this.contentHash,
|
||||
});
|
||||
|
||||
final String specHash;
|
||||
final String configHash;
|
||||
final String contentHash;
|
||||
}
|
||||
|
||||
class _ManifestFileEntry {
|
||||
const _ManifestFileEntry({required this.path, required this.sha256});
|
||||
|
||||
final String path;
|
||||
final String sha256;
|
||||
}
|
||||
|
||||
Future<bool> _fileHasContent(String path, String content) async {
|
||||
final file = File(path);
|
||||
return await file.exists() && await file.readAsString() == content;
|
||||
}
|
||||
|
||||
Future<void> _atomicWrite(File file, String content) async {
|
||||
await file.parent.create(recursive: true);
|
||||
final tempFile = File(
|
||||
'${file.path}.${pid}_${DateTime.now().microsecondsSinceEpoch}.tmp',
|
||||
);
|
||||
try {
|
||||
await tempFile.writeAsString(content, flush: true);
|
||||
await tempFile.rename(file.path);
|
||||
} finally {
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _hashFile(File file) async {
|
||||
return sha256.convert(await file.readAsBytes()).toString();
|
||||
}
|
||||
|
||||
String _hashCanonical(Object? value) {
|
||||
return sha256
|
||||
.convert(utf8.encode(jsonEncode(_canonicalize(value))))
|
||||
.toString();
|
||||
}
|
||||
|
||||
Object? _canonicalize(Object? value) {
|
||||
if (value is Map) {
|
||||
final keys = value.keys.map((key) => key.toString()).toList(growable: false)
|
||||
..sort();
|
||||
return <String, Object?>{
|
||||
for (final key in keys) key: _canonicalize(value[key]),
|
||||
};
|
||||
}
|
||||
if (value is Iterable) {
|
||||
return <Object?>[for (final item in value) _canonicalize(item)];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
+668
-6
@@ -1,51 +1,211 @@
|
||||
enum ModelKind { object, enumType, unionType }
|
||||
import '../diagnostic/diagnostic.dart';
|
||||
|
||||
enum ModelCategory { request, result, shared, enums }
|
||||
/// Structural form used to render a parsed OpenAPI schema.
|
||||
enum ModelKind {
|
||||
/// A class-like schema with named fields.
|
||||
object,
|
||||
|
||||
enum UsageKind { request, result, shared }
|
||||
/// A finite set of serialized values.
|
||||
enumType,
|
||||
|
||||
enum TypeKind { named, list, map, dynamicType, objectType, voidType }
|
||||
/// A wrapper selecting one of several schema branches.
|
||||
unionType,
|
||||
}
|
||||
|
||||
enum ApiRequestBodyKind { json, multipart }
|
||||
/// Strategy used to select and decode union branches.
|
||||
enum UnionMode {
|
||||
/// Selects branches using an OpenAPI discriminator property.
|
||||
discriminated,
|
||||
|
||||
/// Requires exactly one branch to match.
|
||||
oneOf,
|
||||
|
||||
/// Accepts any structurally compatible branch.
|
||||
anyOf,
|
||||
}
|
||||
|
||||
/// Output directory group assigned to a generated model.
|
||||
enum ModelCategory {
|
||||
/// Models used only in request data.
|
||||
request,
|
||||
|
||||
/// Models used only in response data.
|
||||
result,
|
||||
|
||||
/// Models shared by request and response data.
|
||||
shared,
|
||||
|
||||
/// Generated enum declarations.
|
||||
enums,
|
||||
}
|
||||
|
||||
/// Direction in which an OpenAPI schema is used.
|
||||
enum UsageKind {
|
||||
/// The schema participates in request serialization.
|
||||
request,
|
||||
|
||||
/// The schema participates in response deserialization.
|
||||
result,
|
||||
|
||||
/// The schema is explicitly shared across both directions.
|
||||
shared,
|
||||
}
|
||||
|
||||
/// Canonical Dart type shape represented in the generator IR.
|
||||
enum TypeKind {
|
||||
/// A named Dart type, optionally with generic arguments.
|
||||
named,
|
||||
|
||||
/// A Dart `List` type.
|
||||
list,
|
||||
|
||||
/// A string-keyed Dart `Map` type.
|
||||
map,
|
||||
|
||||
/// The Dart `dynamic` type.
|
||||
dynamicType,
|
||||
|
||||
/// The Dart `Object` type.
|
||||
objectType,
|
||||
|
||||
/// The Dart `void` type.
|
||||
voidType,
|
||||
}
|
||||
|
||||
/// Wire encoding selected for an OpenAPI request body.
|
||||
enum ApiRequestBodyKind {
|
||||
/// JSON or another JSON-compatible media type.
|
||||
json,
|
||||
|
||||
/// A `multipart/form-data` body.
|
||||
multipart,
|
||||
|
||||
/// An `application/x-www-form-urlencoded` body.
|
||||
formUrlEncoded,
|
||||
|
||||
/// A binary request body.
|
||||
binary,
|
||||
}
|
||||
|
||||
/// OpenAPI parameter serialization style retained by the transport IR.
|
||||
enum ApiParameterStyle {
|
||||
/// Path-segment parameters prefixed with a semicolon.
|
||||
matrix,
|
||||
|
||||
/// Dot-prefixed path-segment parameters.
|
||||
label,
|
||||
|
||||
/// Form-style query or cookie parameters.
|
||||
form,
|
||||
|
||||
/// Comma-delimited path or header parameters.
|
||||
simple,
|
||||
|
||||
/// Space-delimited query arrays.
|
||||
spaceDelimited,
|
||||
|
||||
/// Pipe-delimited query arrays.
|
||||
pipeDelimited,
|
||||
|
||||
/// Object-shaped query parameters encoded as nested keys.
|
||||
deepObject,
|
||||
}
|
||||
|
||||
/// Decoder category selected for an OpenAPI response body.
|
||||
enum ApiResponseBodyKind {
|
||||
/// The response has no body.
|
||||
none,
|
||||
|
||||
/// The body uses JSON or a JSON-compatible media type.
|
||||
json,
|
||||
|
||||
/// The body is decoded as text.
|
||||
text,
|
||||
|
||||
/// The body is decoded as an in-memory byte list.
|
||||
bytes,
|
||||
|
||||
/// The body is exposed as a byte stream.
|
||||
stream,
|
||||
|
||||
/// The media type is retained without a specialized decoder.
|
||||
other,
|
||||
}
|
||||
|
||||
/// Supported OpenAPI security-scheme categories.
|
||||
enum ApiSecuritySchemeType {
|
||||
/// HTTP authentication such as Basic or Bearer.
|
||||
http,
|
||||
|
||||
/// An API key supplied through a header, query, or cookie.
|
||||
apiKey,
|
||||
|
||||
/// An OAuth 2.0 flow declaration.
|
||||
oauth2,
|
||||
|
||||
/// An OpenID Connect discovery-based scheme.
|
||||
openIdConnect,
|
||||
|
||||
/// Mutual TLS authentication.
|
||||
mutualTls,
|
||||
|
||||
/// An unrecognized scheme retained only as metadata.
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// A renderable Dart type with nullability and generic arguments.
|
||||
class DartType {
|
||||
/// Creates a named type such as `User` or `Future<User>`.
|
||||
const DartType.named(
|
||||
this.name, {
|
||||
this.nullable = false,
|
||||
this.arguments = const <DartType>[],
|
||||
}) : kind = TypeKind.named;
|
||||
|
||||
/// Creates a `List` whose element type is [itemType].
|
||||
DartType.list(DartType itemType, {this.nullable = false})
|
||||
: kind = TypeKind.list,
|
||||
name = 'List',
|
||||
arguments = <DartType>[itemType];
|
||||
|
||||
/// Creates a string-keyed `Map` whose value type is [valueType].
|
||||
DartType.map(DartType valueType, {this.nullable = false})
|
||||
: kind = TypeKind.map,
|
||||
name = 'Map',
|
||||
arguments = <DartType>[const DartType.named('String'), valueType];
|
||||
|
||||
/// Creates the Dart `dynamic` type.
|
||||
const DartType.dynamicType({this.nullable = false})
|
||||
: kind = TypeKind.dynamicType,
|
||||
name = 'dynamic',
|
||||
arguments = const <DartType>[];
|
||||
|
||||
/// Creates the Dart `Object` type.
|
||||
const DartType.objectType({this.nullable = false})
|
||||
: kind = TypeKind.objectType,
|
||||
name = 'Object',
|
||||
arguments = const <DartType>[];
|
||||
|
||||
/// Creates the non-nullable Dart `void` type.
|
||||
const DartType.voidType()
|
||||
: kind = TypeKind.voidType,
|
||||
name = 'void',
|
||||
arguments = const <DartType>[],
|
||||
nullable = false;
|
||||
|
||||
/// Structural category of this type.
|
||||
final TypeKind kind;
|
||||
|
||||
/// Base Dart type name before generic arguments and nullability.
|
||||
final String name;
|
||||
|
||||
/// Generic type arguments in source order.
|
||||
final List<DartType> arguments;
|
||||
|
||||
/// Whether rendered values may be `null`.
|
||||
final bool nullable;
|
||||
|
||||
/// Returns an equivalent nullable type when nullability is meaningful.
|
||||
DartType asNullable() {
|
||||
if (nullable || kind == TypeKind.voidType) {
|
||||
return this;
|
||||
@@ -66,8 +226,10 @@ class DartType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this type is a list or map collection.
|
||||
bool get isCollection => kind == TypeKind.list || kind == TypeKind.map;
|
||||
|
||||
/// Renders this type as valid Dart source.
|
||||
String render() {
|
||||
final suffix = nullable ? '?' : '';
|
||||
return switch (kind) {
|
||||
@@ -83,6 +245,7 @@ class DartType {
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns named types shared with [candidates], including nested arguments.
|
||||
Set<String> collectReferencedNames(Set<String> candidates) {
|
||||
final result = <String>{};
|
||||
if (kind == TypeKind.named && candidates.contains(name)) {
|
||||
@@ -93,48 +256,158 @@ class DartType {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns every named type used by this type and its generic arguments.
|
||||
Set<String> collectNamedNames() {
|
||||
final result = <String>{};
|
||||
if (kind == TypeKind.named) {
|
||||
result.add(name);
|
||||
}
|
||||
for (final argument in arguments) {
|
||||
result.addAll(argument.collectNamedNames());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// A generated model property and its serialization metadata.
|
||||
class ModelField {
|
||||
/// Creates a model field parsed from an OpenAPI property.
|
||||
const ModelField({
|
||||
required this.name,
|
||||
required this.wireName,
|
||||
required this.type,
|
||||
required this.required,
|
||||
this.schemaNullable = false,
|
||||
this.readOnly = false,
|
||||
this.writeOnly = false,
|
||||
this.constraints = const <String, Object?>{},
|
||||
this.description,
|
||||
this.defaultValue,
|
||||
});
|
||||
|
||||
/// Sanitized Dart field name.
|
||||
final String name;
|
||||
|
||||
/// Serialized property name from the OpenAPI document.
|
||||
final String wireName;
|
||||
|
||||
/// Dart type used by generated source.
|
||||
final DartType type;
|
||||
|
||||
/// Whether the containing schema lists this property as required.
|
||||
final bool required;
|
||||
|
||||
/// Whether the source schema explicitly permits `null`.
|
||||
final bool schemaNullable;
|
||||
|
||||
/// Whether the property is present only during deserialization.
|
||||
final bool readOnly;
|
||||
|
||||
/// Whether the property is present only during serialization.
|
||||
final bool writeOnly;
|
||||
|
||||
/// Validation keywords retained for generation or downstream inspection.
|
||||
final Map<String, Object?> constraints;
|
||||
|
||||
/// Human-readable OpenAPI property description.
|
||||
final String? description;
|
||||
|
||||
/// Default value declared by the source schema.
|
||||
final Object? defaultValue;
|
||||
|
||||
/// Whether serialized input may omit this field.
|
||||
bool get allowsAbsent => !required;
|
||||
}
|
||||
|
||||
/// A generated enum member and its serialized wire value.
|
||||
class EnumValue {
|
||||
const EnumValue({required this.name, required this.rawValue});
|
||||
/// Creates an enum member.
|
||||
const EnumValue({
|
||||
required this.name,
|
||||
required this.rawValue,
|
||||
this.description,
|
||||
this.isFallback = false,
|
||||
});
|
||||
|
||||
/// Sanitized Dart member name.
|
||||
final String name;
|
||||
|
||||
/// Value serialized to and deserialized from the wire format.
|
||||
final Object rawValue;
|
||||
|
||||
/// Human-readable description for generated API documentation.
|
||||
final String? description;
|
||||
|
||||
/// Whether this member receives unknown wire values.
|
||||
final bool isFallback;
|
||||
}
|
||||
|
||||
/// One branch of a generated discriminated or structural union.
|
||||
class UnionCase {
|
||||
/// Creates a union branch.
|
||||
const UnionCase({
|
||||
required this.caseName,
|
||||
required this.wireValue,
|
||||
required this.type,
|
||||
required this.originalName,
|
||||
this.matchKind = UnionMatchKind.object,
|
||||
this.requiredProperties = const <String>[],
|
||||
this.allowedValues = const <Object>[],
|
||||
});
|
||||
|
||||
/// Sanitized case name used by the generated wrapper.
|
||||
final String caseName;
|
||||
|
||||
/// Discriminator value selecting this branch when applicable.
|
||||
final String wireValue;
|
||||
|
||||
/// Dart payload type represented by this branch.
|
||||
final DartType type;
|
||||
|
||||
/// Original component or inline schema name.
|
||||
final String originalName;
|
||||
|
||||
/// Runtime value shape used for structural matching.
|
||||
final UnionMatchKind matchKind;
|
||||
|
||||
/// Object properties required for this branch to match.
|
||||
final List<String> requiredProperties;
|
||||
|
||||
/// Literal values accepted by this branch.
|
||||
final List<Object> allowedValues;
|
||||
}
|
||||
|
||||
/// Runtime JSON shape used to match a union branch.
|
||||
enum UnionMatchKind {
|
||||
/// A JSON string.
|
||||
string,
|
||||
|
||||
/// A JSON integer.
|
||||
integer,
|
||||
|
||||
/// Any JSON number.
|
||||
number,
|
||||
|
||||
/// A JSON boolean.
|
||||
boolean,
|
||||
|
||||
/// A JSON object.
|
||||
object,
|
||||
|
||||
/// A JSON array.
|
||||
array,
|
||||
|
||||
/// The JSON `null` value.
|
||||
nullValue,
|
||||
|
||||
/// Any JSON value.
|
||||
any,
|
||||
}
|
||||
|
||||
/// Complete generator metadata for one model declaration.
|
||||
class ModelSpec {
|
||||
/// Creates a model specification with mutable usage classification.
|
||||
ModelSpec({
|
||||
required this.name,
|
||||
required this.originalName,
|
||||
@@ -143,22 +416,48 @@ class ModelSpec {
|
||||
List<EnumValue>? enumValues,
|
||||
List<UnionCase>? unionCases,
|
||||
this.discriminatorKey,
|
||||
this.unionMode = UnionMode.discriminated,
|
||||
this.description,
|
||||
this.additionalPropertiesType,
|
||||
}) : fields = fields ?? <ModelField>[],
|
||||
enumValues = enumValues ?? <EnumValue>[],
|
||||
unionCases = unionCases ?? <UnionCase>[],
|
||||
usages = <UsageKind>{};
|
||||
|
||||
/// Sanitized generated Dart type name.
|
||||
final String name;
|
||||
|
||||
/// Original component or inline schema name.
|
||||
final String originalName;
|
||||
|
||||
/// Structural form of this model.
|
||||
final ModelKind kind;
|
||||
|
||||
/// Object properties when [kind] is [ModelKind.object].
|
||||
final List<ModelField> fields;
|
||||
|
||||
/// Enum members when [kind] is [ModelKind.enumType].
|
||||
final List<EnumValue> enumValues;
|
||||
|
||||
/// Union branches when [kind] is [ModelKind.unionType].
|
||||
final List<UnionCase> unionCases;
|
||||
|
||||
/// Serialized property used to select a discriminated union branch.
|
||||
final String? discriminatorKey;
|
||||
|
||||
/// Branch-selection semantics for a union model.
|
||||
final UnionMode unionMode;
|
||||
|
||||
/// Human-readable schema description.
|
||||
final String? description;
|
||||
|
||||
/// Value type for undeclared object properties, when supported.
|
||||
DartType? additionalPropertiesType;
|
||||
|
||||
/// Request and response directions that reference this model.
|
||||
final Set<UsageKind> usages;
|
||||
|
||||
/// Output group derived from [kind] and [usages].
|
||||
ModelCategory get category {
|
||||
if (kind == ModelKind.enumType) {
|
||||
return ModelCategory.enums;
|
||||
@@ -178,27 +477,77 @@ class ModelSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// A path, query, header, or cookie parameter for a generated operation.
|
||||
class ApiParameter {
|
||||
/// Creates an operation parameter with resolved serialization semantics.
|
||||
const ApiParameter({
|
||||
required this.name,
|
||||
required this.wireName,
|
||||
required this.location,
|
||||
required this.type,
|
||||
required this.required,
|
||||
required this.style,
|
||||
required this.explode,
|
||||
this.allowReserved = false,
|
||||
this.contentType,
|
||||
this.description,
|
||||
this.defaultValue,
|
||||
});
|
||||
|
||||
/// Sanitized Dart argument name.
|
||||
final String name;
|
||||
|
||||
/// Parameter name used on the wire.
|
||||
final String wireName;
|
||||
|
||||
/// OpenAPI parameter location such as `path` or `query`.
|
||||
final String location;
|
||||
|
||||
/// Generated Dart argument type.
|
||||
final DartType type;
|
||||
|
||||
/// Whether callers must provide the parameter.
|
||||
final bool required;
|
||||
|
||||
/// OpenAPI serialization style.
|
||||
final ApiParameterStyle style;
|
||||
|
||||
/// Whether arrays and objects expand into separate wire values.
|
||||
final bool explode;
|
||||
|
||||
/// Whether reserved characters remain unescaped for query parameters.
|
||||
final bool allowReserved;
|
||||
|
||||
/// Media type used by a content-based parameter.
|
||||
final String? contentType;
|
||||
|
||||
/// Human-readable OpenAPI parameter description.
|
||||
final String? description;
|
||||
|
||||
/// Default value declared by the parameter schema.
|
||||
final Object? defaultValue;
|
||||
|
||||
/// Returns a copy with Dart argument name [value].
|
||||
ApiParameter withName(String value) {
|
||||
return ApiParameter(
|
||||
name: value,
|
||||
wireName: wireName,
|
||||
location: location,
|
||||
type: type,
|
||||
required: required,
|
||||
style: style,
|
||||
explode: explode,
|
||||
allowReserved: allowReserved,
|
||||
contentType: contentType,
|
||||
description: description,
|
||||
defaultValue: defaultValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One field in a multipart or form-url-encoded request body.
|
||||
class ApiMultipartField {
|
||||
/// Creates a structured request-body field.
|
||||
const ApiMultipartField({
|
||||
required this.name,
|
||||
required this.wireName,
|
||||
@@ -208,15 +557,28 @@ class ApiMultipartField {
|
||||
this.contentType,
|
||||
});
|
||||
|
||||
/// Sanitized Dart argument or local variable name.
|
||||
final String name;
|
||||
|
||||
/// Field name used on the wire.
|
||||
final String wireName;
|
||||
|
||||
/// Generated Dart value type.
|
||||
final DartType type;
|
||||
|
||||
/// Whether callers must provide this field.
|
||||
final bool required;
|
||||
|
||||
/// Human-readable schema description.
|
||||
final String? description;
|
||||
|
||||
/// Optional per-field media type.
|
||||
final String? contentType;
|
||||
}
|
||||
|
||||
/// Parsed request body for a generated operation.
|
||||
class ApiRequestBody {
|
||||
/// Creates a request body with its selected wire encoding.
|
||||
const ApiRequestBody({
|
||||
required this.kind,
|
||||
required this.required,
|
||||
@@ -224,17 +586,173 @@ class ApiRequestBody {
|
||||
this.type,
|
||||
this.contentType,
|
||||
this.multipartFields = const <ApiMultipartField>[],
|
||||
this.formFields = const <ApiMultipartField>[],
|
||||
});
|
||||
|
||||
/// Wire encoding used by the request body.
|
||||
final ApiRequestBodyKind kind;
|
||||
|
||||
/// Dart payload type for non-structured request bodies.
|
||||
final DartType? type;
|
||||
|
||||
/// Whether callers must supply the body.
|
||||
final bool required;
|
||||
|
||||
/// Sanitized Dart parameter name.
|
||||
final String parameterName;
|
||||
|
||||
/// Selected request media type.
|
||||
final String? contentType;
|
||||
|
||||
/// Fields emitted for a multipart request.
|
||||
final List<ApiMultipartField> multipartFields;
|
||||
|
||||
/// Fields emitted for a form-url-encoded request.
|
||||
final List<ApiMultipartField> formFields;
|
||||
}
|
||||
|
||||
/// One response header retained in operation metadata.
|
||||
class ApiResponseHeader {
|
||||
/// Creates a parsed response header.
|
||||
const ApiResponseHeader({
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.required,
|
||||
this.description,
|
||||
});
|
||||
|
||||
/// Header name used on the wire.
|
||||
final String name;
|
||||
|
||||
/// Generated Dart value type.
|
||||
final DartType type;
|
||||
|
||||
/// Whether the response declares this header as required.
|
||||
final bool required;
|
||||
|
||||
/// Human-readable OpenAPI header description.
|
||||
final String? description;
|
||||
}
|
||||
|
||||
/// One declared response for a generated operation.
|
||||
class ApiResponse {
|
||||
/// Creates a parsed operation response.
|
||||
const ApiResponse({
|
||||
required this.statusCode,
|
||||
required this.isSuccess,
|
||||
required this.isDefault,
|
||||
required this.bodyKind,
|
||||
required this.bodyType,
|
||||
this.contentType,
|
||||
this.description,
|
||||
this.headers = const <ApiResponseHeader>[],
|
||||
});
|
||||
|
||||
/// OpenAPI response key such as `200`, `2XX`, or `default`.
|
||||
final String statusCode;
|
||||
|
||||
/// Whether this response is an explicit successful status.
|
||||
final bool isSuccess;
|
||||
|
||||
/// Whether this is the OpenAPI `default` response.
|
||||
final bool isDefault;
|
||||
|
||||
/// Decoder category selected for the response body.
|
||||
final ApiResponseBodyKind bodyKind;
|
||||
|
||||
/// Generated Dart type of the decoded response body.
|
||||
final DartType bodyType;
|
||||
|
||||
/// Selected response media type.
|
||||
final String? contentType;
|
||||
|
||||
/// Human-readable OpenAPI response description.
|
||||
final String? description;
|
||||
|
||||
/// Parsed response headers.
|
||||
final List<ApiResponseHeader> headers;
|
||||
|
||||
/// Whether this response declares a decodable body.
|
||||
bool get hasBody => bodyKind != ApiResponseBodyKind.none;
|
||||
}
|
||||
|
||||
/// A resolved OpenAPI server URL and its substituted variables.
|
||||
class ApiServer {
|
||||
/// Creates a server metadata entry.
|
||||
const ApiServer({
|
||||
required this.url,
|
||||
this.description,
|
||||
this.variables = const <String, String>{},
|
||||
});
|
||||
|
||||
/// Server URL after applying declared variable defaults.
|
||||
final String url;
|
||||
|
||||
/// Human-readable server description.
|
||||
final String? description;
|
||||
|
||||
/// Resolved server-variable values keyed by variable name.
|
||||
final Map<String, String> variables;
|
||||
}
|
||||
|
||||
/// A parsed OpenAPI security scheme retained for client integration.
|
||||
class ApiSecurityScheme {
|
||||
/// Creates a security-scheme metadata entry.
|
||||
const ApiSecurityScheme({
|
||||
required this.name,
|
||||
required this.type,
|
||||
this.scheme,
|
||||
this.bearerFormat,
|
||||
this.parameterName,
|
||||
this.location,
|
||||
this.description,
|
||||
this.openIdConnectUrl,
|
||||
this.oauthScopes = const <String>[],
|
||||
});
|
||||
|
||||
/// Component name of the security scheme.
|
||||
final String name;
|
||||
|
||||
/// Normalized security-scheme category.
|
||||
final ApiSecuritySchemeType type;
|
||||
|
||||
/// HTTP authentication scheme such as `basic` or `bearer`.
|
||||
final String? scheme;
|
||||
|
||||
/// Optional bearer token format hint.
|
||||
final String? bearerFormat;
|
||||
|
||||
/// Wire parameter name for an API-key scheme.
|
||||
final String? parameterName;
|
||||
|
||||
/// Wire location for an API-key scheme.
|
||||
final String? location;
|
||||
|
||||
/// Human-readable security-scheme description.
|
||||
final String? description;
|
||||
|
||||
/// Discovery URL for an OpenID Connect scheme.
|
||||
final String? openIdConnectUrl;
|
||||
|
||||
/// OAuth scopes declared across supported flows.
|
||||
final List<String> oauthScopes;
|
||||
}
|
||||
|
||||
/// One AND-group inside an OpenAPI security requirement list.
|
||||
class ApiSecurityRequirement {
|
||||
/// Creates a requirement mapping scheme names to requested scopes.
|
||||
const ApiSecurityRequirement({required this.schemes});
|
||||
|
||||
/// Required schemes and their OAuth scopes.
|
||||
final Map<String, List<String>> schemes;
|
||||
|
||||
/// Whether this requirement permits a request without credentials.
|
||||
bool get allowsAnonymous => schemes.isEmpty;
|
||||
}
|
||||
|
||||
/// Complete transport and type metadata for one generated API operation.
|
||||
class ApiOperation {
|
||||
/// Creates a parsed API operation.
|
||||
const ApiOperation({
|
||||
required this.path,
|
||||
required this.method,
|
||||
@@ -243,57 +761,158 @@ class ApiOperation {
|
||||
required this.methodName,
|
||||
required this.returnType,
|
||||
required this.parameters,
|
||||
this.responses = const <ApiResponse>[],
|
||||
this.primaryResponseStatusCode,
|
||||
this.serverUrl,
|
||||
this.security = const <ApiSecurityRequirement>[],
|
||||
this.requestBody,
|
||||
this.summary,
|
||||
this.description,
|
||||
this.deprecated = false,
|
||||
});
|
||||
|
||||
/// OpenAPI path template.
|
||||
final String path;
|
||||
|
||||
/// Lowercase HTTP method.
|
||||
final String method;
|
||||
|
||||
/// Selected OpenAPI tag used for grouping.
|
||||
final String tag;
|
||||
|
||||
/// API version used in generated output paths.
|
||||
final String version;
|
||||
|
||||
/// Sanitized generated Dart method name.
|
||||
final String methodName;
|
||||
|
||||
/// Generated method return type.
|
||||
final DartType returnType;
|
||||
|
||||
/// Path, query, header, and cookie parameters.
|
||||
final List<ApiParameter> parameters;
|
||||
|
||||
/// All declared operation responses.
|
||||
final List<ApiResponse> responses;
|
||||
|
||||
/// Status key selected as the primary successful response.
|
||||
final String? primaryResponseStatusCode;
|
||||
|
||||
/// Operation- or path-level server override.
|
||||
final String? serverUrl;
|
||||
|
||||
/// Alternative security requirements; list entries are combined with OR.
|
||||
final List<ApiSecurityRequirement> security;
|
||||
|
||||
/// Parsed request body, when present.
|
||||
final ApiRequestBody? requestBody;
|
||||
|
||||
/// Short OpenAPI operation summary.
|
||||
final String? summary;
|
||||
|
||||
/// Detailed OpenAPI operation description.
|
||||
final String? description;
|
||||
|
||||
/// Whether the OpenAPI operation is deprecated.
|
||||
final bool deprecated;
|
||||
|
||||
/// Selected successful response, preferring [primaryResponseStatusCode].
|
||||
ApiResponse? get primarySuccessResponse {
|
||||
final selectedStatusCode = primaryResponseStatusCode;
|
||||
if (selectedStatusCode != null) {
|
||||
for (final response in responses) {
|
||||
if (response.statusCode == selectedStatusCode) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final response in responses) {
|
||||
if (response.isSuccess && response.hasBody) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
for (final response in responses) {
|
||||
if (response.isSuccess) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic intermediate representation of all selected API content.
|
||||
class ParsedApiDocument {
|
||||
/// Creates a parsed document ready for Dart rendering.
|
||||
const ParsedApiDocument({
|
||||
required this.models,
|
||||
required this.operations,
|
||||
required this.sourceDocuments,
|
||||
this.servers = const <ApiServer>[],
|
||||
this.securitySchemes = const <String, ApiSecurityScheme>{},
|
||||
this.globalSecurity = const <ApiSecurityRequirement>[],
|
||||
this.dependencyDocuments = const <String>[],
|
||||
this.diagnostics = const <KoiDiagnostic>[],
|
||||
});
|
||||
|
||||
/// Generated models keyed by sanitized Dart type name.
|
||||
final Map<String, ModelSpec> models;
|
||||
|
||||
/// Selected operations in deterministic output order.
|
||||
final List<ApiOperation> operations;
|
||||
|
||||
/// Canonical root OpenAPI documents.
|
||||
final List<String> sourceDocuments;
|
||||
|
||||
/// Root-level resolved servers.
|
||||
final List<ApiServer> servers;
|
||||
|
||||
/// Parsed security schemes keyed by component name.
|
||||
final Map<String, ApiSecurityScheme> securitySchemes;
|
||||
|
||||
/// Root-level alternative security requirements.
|
||||
final List<ApiSecurityRequirement> globalSecurity;
|
||||
|
||||
/// External documents loaded through references.
|
||||
final List<String> dependencyDocuments;
|
||||
|
||||
/// Informational and warning diagnostics retained during parsing.
|
||||
final List<KoiDiagnostic> diagnostics;
|
||||
}
|
||||
|
||||
/// One rendered source file before lifecycle writes are applied.
|
||||
class GeneratedFile {
|
||||
/// Creates a rendered file at [path] with [content].
|
||||
const GeneratedFile({required this.path, required this.content});
|
||||
|
||||
/// Absolute destination path.
|
||||
final String path;
|
||||
|
||||
/// Complete generated Dart source.
|
||||
final String content;
|
||||
}
|
||||
|
||||
/// Rendered files and aggregate counts from one generation pass.
|
||||
class GeneratedBundle {
|
||||
/// Creates a generated bundle.
|
||||
const GeneratedBundle({
|
||||
required this.files,
|
||||
required this.operationsCount,
|
||||
required this.modelsCount,
|
||||
});
|
||||
|
||||
/// Rendered files ready for lifecycle processing.
|
||||
final List<GeneratedFile> files;
|
||||
|
||||
/// Number of operations included in this bundle.
|
||||
final int operationsCount;
|
||||
|
||||
/// Number of models included in this bundle.
|
||||
final int modelsCount;
|
||||
}
|
||||
|
||||
/// Summarizes the output and diagnostics of one generation run.
|
||||
class GenerationResult {
|
||||
/// Creates a generation result.
|
||||
const GenerationResult({
|
||||
required this.configPath,
|
||||
required this.sourceDocuments,
|
||||
@@ -301,12 +920,55 @@ class GenerationResult {
|
||||
required this.operationsCount,
|
||||
required this.modelsCount,
|
||||
required this.dryRun,
|
||||
this.generatedFiles = const <String>[],
|
||||
this.deletedFiles = const <String>[],
|
||||
this.wouldWriteFiles = const <String>[],
|
||||
this.wouldDeleteFiles = const <String>[],
|
||||
this.skippedUnchanged = false,
|
||||
this.manifestPath,
|
||||
this.dependencyDocuments = const <String>[],
|
||||
this.diagnostics = const <KoiDiagnostic>[],
|
||||
});
|
||||
|
||||
/// The resolved configuration file path, or `null` when defaults were used.
|
||||
final String? configPath;
|
||||
|
||||
/// Root OpenAPI documents loaded for this run.
|
||||
final List<String> sourceDocuments;
|
||||
|
||||
/// All files represented by the generated output bundle.
|
||||
final List<String> generatedFiles;
|
||||
|
||||
/// Files written or updated during this run.
|
||||
final List<String> writtenFiles;
|
||||
|
||||
/// Stale generated files deleted during this run.
|
||||
final List<String> deletedFiles;
|
||||
|
||||
/// Files that would be written when [dryRun] is `true`.
|
||||
final List<String> wouldWriteFiles;
|
||||
|
||||
/// Files that would be deleted when [dryRun] is `true`.
|
||||
final List<String> wouldDeleteFiles;
|
||||
|
||||
/// Whether generation skipped writing because all output was unchanged.
|
||||
final bool skippedUnchanged;
|
||||
|
||||
/// Path to the generated-file lifecycle manifest, when one is configured.
|
||||
final String? manifestPath;
|
||||
|
||||
/// Number of OpenAPI operations included in generated output.
|
||||
final int operationsCount;
|
||||
|
||||
/// Number of OpenAPI models included in generated output.
|
||||
final int modelsCount;
|
||||
|
||||
/// Whether this run only calculated changes without modifying files.
|
||||
final bool dryRun;
|
||||
|
||||
/// Referenced OpenAPI documents loaded in addition to [sourceDocuments].
|
||||
final List<String> dependencyDocuments;
|
||||
|
||||
/// Informational and warning diagnostics retained for this run.
|
||||
final List<KoiDiagnostic> diagnostics;
|
||||
}
|
||||
|
||||
+139
-20
@@ -1,32 +1,55 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'config/config.dart';
|
||||
import 'diagnostic/diagnostic.dart';
|
||||
import 'generator/generator.dart';
|
||||
import 'generator/lifecycle.dart';
|
||||
import 'ir/ir.dart';
|
||||
import 'openapi/openapi.dart';
|
||||
|
||||
/// Generates Dart API clients and models from configured OpenAPI documents.
|
||||
class KoiSwaggerParser {
|
||||
/// Creates a stateless parser and generator facade.
|
||||
const KoiSwaggerParser();
|
||||
|
||||
/// Loads configuration, parses OpenAPI inputs, and generates Dart sources.
|
||||
///
|
||||
/// [configPath] selects a YAML configuration file. When omitted, the parser
|
||||
/// searches [workingDirectory], or the current directory, for a supported
|
||||
/// default filename.
|
||||
///
|
||||
/// When [dryRun] is `true`, no generated file is written or deleted, and the
|
||||
/// proposed changes are returned in the [GenerationResult]. Set [verbose] to
|
||||
/// print progress to standard output. Set [buildRunnerWatch] to start
|
||||
/// `build_runner watch` after a successful non-dry generation.
|
||||
///
|
||||
/// Throws [KoiDiagnosticException] when configuration or OpenAPI validation
|
||||
/// reports errors. File, network, formatting, and build failures retain their
|
||||
/// underlying exception types.
|
||||
Future<GenerationResult> generate({
|
||||
String? configPath,
|
||||
String? workingDirectory,
|
||||
bool dryRun = false,
|
||||
bool verbose = false,
|
||||
bool buildRunnerWatch = false,
|
||||
}) async {
|
||||
final config = await KoiSwaggerConfig.load(
|
||||
configPath: configPath,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
if (config.input.swaggerUrls.isEmpty) {
|
||||
throw StateError('没有可用的 OpenAPI 文档源,请检查配置文件的 input.swagger_urls。');
|
||||
throw StateError(
|
||||
'No OpenAPI document source is available. Check '
|
||||
'input.swagger_urls in the configuration file.',
|
||||
);
|
||||
}
|
||||
|
||||
final loader = OpenApiDocumentLoader();
|
||||
final loaded = await loader.load(config.input.swaggerUrls);
|
||||
if (verbose) {
|
||||
stdout.writeln(
|
||||
'[koi_swagger_parser] loaded ${loaded.sources.length} source(s).',
|
||||
'[koi_swagger_parser] loaded ${loaded.sources.length} root source(s) '
|
||||
'and ${loaded.dependencies.length} referenced source(s).',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,37 +65,67 @@ class KoiSwaggerParser {
|
||||
|
||||
final generator = DartGenerator(config);
|
||||
final bundle = generator.generate(document);
|
||||
final filePaths = bundle.files
|
||||
.map((file) => file.path)
|
||||
.toList(growable: false);
|
||||
final formatting = config.generation.formatting;
|
||||
final lifecycle = GenerationLifecycle(config);
|
||||
final lifecycleResult = await lifecycle.apply(
|
||||
bundle: bundle,
|
||||
sourceDocument: loaded.document,
|
||||
sources: loaded.sources,
|
||||
dependencies: loaded.dependencies,
|
||||
dryRun: dryRun,
|
||||
postWrite: (changedFiles) async {
|
||||
if (changedFiles.isNotEmpty && formatting.runDartFix) {
|
||||
await _runFixer(changedFiles);
|
||||
}
|
||||
if (changedFiles.isNotEmpty && formatting.runDartFormat) {
|
||||
await _runFormatter(changedFiles);
|
||||
}
|
||||
if (formatting.runBuildRunner &&
|
||||
formatting.buildRunnerMode == BuildRunnerMode.build) {
|
||||
await _runBuildRunner(config, mode: BuildRunnerMode.build);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!dryRun) {
|
||||
for (final file in bundle.files) {
|
||||
final ioFile = File(file.path);
|
||||
ioFile.parent.createSync(recursive: true);
|
||||
ioFile.writeAsStringSync(file.content);
|
||||
}
|
||||
|
||||
if (config.generation.formatting.runDartFormat) {
|
||||
await _runFormatter(<String>[
|
||||
config.output.apiDir,
|
||||
config.output.modelsDir,
|
||||
]);
|
||||
}
|
||||
final shouldWatch =
|
||||
!dryRun &&
|
||||
(buildRunnerWatch ||
|
||||
(formatting.runBuildRunner &&
|
||||
formatting.buildRunnerMode == BuildRunnerMode.watch));
|
||||
if (shouldWatch) {
|
||||
await _runBuildRunner(config, mode: BuildRunnerMode.watch);
|
||||
}
|
||||
|
||||
return GenerationResult(
|
||||
configPath: config.configPath,
|
||||
sourceDocuments: loaded.sources,
|
||||
writtenFiles: filePaths,
|
||||
generatedFiles: lifecycleResult.generatedFiles,
|
||||
writtenFiles: lifecycleResult.writtenFiles,
|
||||
deletedFiles: lifecycleResult.deletedFiles,
|
||||
wouldWriteFiles: lifecycleResult.wouldWriteFiles,
|
||||
wouldDeleteFiles: lifecycleResult.wouldDeleteFiles,
|
||||
skippedUnchanged: lifecycleResult.skippedUnchanged,
|
||||
manifestPath: lifecycleResult.manifestPath,
|
||||
operationsCount: bundle.operationsCount,
|
||||
modelsCount: bundle.modelsCount,
|
||||
dryRun: dryRun,
|
||||
dependencyDocuments: document.dependencyDocuments,
|
||||
diagnostics: <KoiDiagnostic>[
|
||||
...config.diagnostics,
|
||||
...document.diagnostics,
|
||||
...lifecycleResult.diagnostics,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runFormatter(List<String> targets) async {
|
||||
final uniqueTargets = targets.toSet().toList(growable: false);
|
||||
final uniqueTargets = targets
|
||||
.toSet()
|
||||
.where((target) => File(target).existsSync())
|
||||
.toList(growable: false);
|
||||
if (uniqueTargets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final result = await Process.run(Platform.resolvedExecutable, <String>[
|
||||
'format',
|
||||
...uniqueTargets,
|
||||
@@ -86,4 +139,70 @@ class KoiSwaggerParser {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runFixer(List<String> targets) async {
|
||||
final uniqueTargets = targets.toSet().where(
|
||||
(target) => File(target).existsSync() || Directory(target).existsSync(),
|
||||
);
|
||||
for (final target in uniqueTargets) {
|
||||
final result = await Process.run(Platform.resolvedExecutable, <String>[
|
||||
'fix',
|
||||
'--apply',
|
||||
target,
|
||||
]);
|
||||
if (result.exitCode != 0) {
|
||||
throw ProcessException(
|
||||
Platform.resolvedExecutable,
|
||||
<String>['fix', '--apply', target],
|
||||
result.stderr.toString(),
|
||||
result.exitCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runBuildRunner(
|
||||
KoiSwaggerConfig config, {
|
||||
required BuildRunnerMode mode,
|
||||
}) async {
|
||||
final arguments = <String>[
|
||||
'run',
|
||||
'build_runner',
|
||||
mode.name,
|
||||
if (config.generation.formatting.deleteConflictingOutputs)
|
||||
'--delete-conflicting-outputs',
|
||||
];
|
||||
if (mode == BuildRunnerMode.watch) {
|
||||
final process = await Process.start(
|
||||
Platform.resolvedExecutable,
|
||||
arguments,
|
||||
workingDirectory: config.workingDirectory,
|
||||
mode: ProcessStartMode.inheritStdio,
|
||||
);
|
||||
final exitCode = await process.exitCode;
|
||||
if (exitCode != 0) {
|
||||
throw ProcessException(
|
||||
Platform.resolvedExecutable,
|
||||
arguments,
|
||||
'build_runner watch exited with a non-zero status.',
|
||||
exitCode,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await Process.run(
|
||||
Platform.resolvedExecutable,
|
||||
arguments,
|
||||
workingDirectory: config.workingDirectory,
|
||||
);
|
||||
if (result.exitCode != 0) {
|
||||
throw ProcessException(
|
||||
Platform.resolvedExecutable,
|
||||
arguments,
|
||||
'${result.stdout}\n${result.stderr}',
|
||||
result.exitCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../diagnostic/diagnostic.dart';
|
||||
import '../util/utils.dart';
|
||||
|
||||
/// Merges resolved OpenAPI roots while diagnosing semantic conflicts.
|
||||
class OpenApiDocumentMerger {
|
||||
/// Creates a merger that reports issues to [diagnostics].
|
||||
OpenApiDocumentMerger(this.diagnostics);
|
||||
|
||||
static const Set<String> _httpMethods = <String>{
|
||||
'get',
|
||||
'put',
|
||||
'post',
|
||||
'delete',
|
||||
'options',
|
||||
'head',
|
||||
'patch',
|
||||
'trace',
|
||||
};
|
||||
|
||||
/// Destination for merge warnings and errors.
|
||||
final KoiDiagnosticBag diagnostics;
|
||||
|
||||
/// Merges [incoming] into [base] using deterministic conflict handling.
|
||||
Map<String, dynamic> merge(
|
||||
Map<String, dynamic> base,
|
||||
Map<String, dynamic> incoming, {
|
||||
required String source,
|
||||
}) {
|
||||
final prepared = _deepCopyMap(incoming);
|
||||
_validateVersions(base, prepared, source);
|
||||
_renameConflictingComponents(base, prepared, source);
|
||||
_validatePathConflicts(base, prepared, source);
|
||||
|
||||
final baseInfo = base['info'];
|
||||
if (baseInfo != null &&
|
||||
prepared['info'] != null &&
|
||||
!_structurallyEqual(baseInfo, prepared['info'])) {
|
||||
diagnostics.warning(
|
||||
'openapi.multisource_info_ignored',
|
||||
'The source documents define different info objects; the merged '
|
||||
'document keeps the info object from the first root document.',
|
||||
source: source,
|
||||
location: r'$.info',
|
||||
);
|
||||
}
|
||||
|
||||
final result = deepMergeMaps(base, prepared);
|
||||
if (baseInfo != null) {
|
||||
result['info'] = baseInfo;
|
||||
}
|
||||
if (base['openapi'] != null) {
|
||||
result['openapi'] = base['openapi'];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void _validateVersions(
|
||||
Map<String, dynamic> base,
|
||||
Map<String, dynamic> incoming,
|
||||
String source,
|
||||
) {
|
||||
final baseVersion = base['openapi']?.toString();
|
||||
final incomingVersion = incoming['openapi']?.toString();
|
||||
if (baseVersion == null || incomingVersion == null) {
|
||||
return;
|
||||
}
|
||||
String family(String version) {
|
||||
final parts = version.split('.');
|
||||
return parts.length < 2 ? version : '${parts[0]}.${parts[1]}';
|
||||
}
|
||||
|
||||
if (family(baseVersion) != family(incomingVersion)) {
|
||||
diagnostics.error(
|
||||
'openapi.multisource_version_conflict',
|
||||
'Cannot merge OpenAPI $baseVersion and OpenAPI $incomingVersion '
|
||||
'documents.',
|
||||
source: source,
|
||||
location: r'$.openapi',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _renameConflictingComponents(
|
||||
Map<String, dynamic> base,
|
||||
Map<String, dynamic> incoming,
|
||||
String source,
|
||||
) {
|
||||
final baseComponents = base['components'];
|
||||
final incomingComponents = incoming['components'];
|
||||
if (baseComponents is! Map || incomingComponents is! Map) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final groupEntry in incomingComponents.entries.toList()) {
|
||||
final kind = groupEntry.key.toString();
|
||||
final incomingGroup = groupEntry.value;
|
||||
final baseGroup = baseComponents[kind];
|
||||
if (incomingGroup is! Map || baseGroup is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final componentEntry in incomingGroup.entries.toList()) {
|
||||
final name = componentEntry.key.toString();
|
||||
if (!baseGroup.containsKey(name)) {
|
||||
continue;
|
||||
}
|
||||
if (_structurallyEqual(baseGroup[name], componentEntry.value)) {
|
||||
incomingGroup.remove(componentEntry.key);
|
||||
continue;
|
||||
}
|
||||
|
||||
final renamed = _uniqueComponentName(
|
||||
source,
|
||||
name,
|
||||
baseGroup.keys,
|
||||
incomingGroup.keys,
|
||||
);
|
||||
incomingGroup
|
||||
..remove(componentEntry.key)
|
||||
..[renamed] = componentEntry.value;
|
||||
|
||||
final oldRef =
|
||||
'#/components/${_escapePointerSegment(kind)}/'
|
||||
'${_escapePointerSegment(name)}';
|
||||
final newRef =
|
||||
'#/components/${_escapePointerSegment(kind)}/'
|
||||
'${_escapePointerSegment(renamed)}';
|
||||
_rewriteRefs(incoming, oldRef, newRef);
|
||||
diagnostics.warning(
|
||||
'openapi.multisource_component_renamed',
|
||||
'Component `$kind/$name` conflicts with an earlier source and was '
|
||||
'renamed to `$renamed`.',
|
||||
source: source,
|
||||
location: '#/components/$kind/$name',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _validatePathConflicts(
|
||||
Map<String, dynamic> base,
|
||||
Map<String, dynamic> incoming,
|
||||
String source,
|
||||
) {
|
||||
final basePaths = base['paths'];
|
||||
final incomingPaths = incoming['paths'];
|
||||
if (basePaths is! Map || incomingPaths is! Map) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final pathEntry in incomingPaths.entries) {
|
||||
final path = pathEntry.key.toString();
|
||||
final incomingPathItem = pathEntry.value;
|
||||
final basePathItem = basePaths[path];
|
||||
if (incomingPathItem is! Map || basePathItem is! Map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final method in _httpMethods) {
|
||||
if (basePathItem[method] != null && incomingPathItem[method] != null) {
|
||||
diagnostics.error(
|
||||
'openapi.multisource_operation_conflict',
|
||||
'Multiple source documents define '
|
||||
'`${method.toUpperCase()} $path`.',
|
||||
source: source,
|
||||
location: '\$.paths.$path.$method',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final baseParameters = basePathItem['parameters'];
|
||||
final incomingParameters = incomingPathItem['parameters'];
|
||||
if (baseParameters != null &&
|
||||
incomingParameters != null &&
|
||||
!_structurallyEqual(baseParameters, incomingParameters)) {
|
||||
diagnostics.error(
|
||||
'openapi.multisource_path_parameters_conflict',
|
||||
'Multiple source documents define conflicting shared parameters '
|
||||
'for path `$path`.',
|
||||
source: source,
|
||||
location: '\$.paths.$path.parameters',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _uniqueComponentName(
|
||||
String source,
|
||||
String originalName,
|
||||
Iterable<dynamic> baseNames,
|
||||
Iterable<dynamic> incomingNames,
|
||||
) {
|
||||
final used = <String>{
|
||||
...baseNames.map((name) => name.toString()),
|
||||
...incomingNames.map((name) => name.toString()),
|
||||
};
|
||||
final path = isHttpUrl(source) ? Uri.parse(source).path : source;
|
||||
final stem = toSnakeCase(p.basenameWithoutExtension(path));
|
||||
final prefix = stem.isEmpty ? 'source' : stem;
|
||||
final baseName = '${prefix}_$originalName';
|
||||
var candidate = baseName;
|
||||
var suffix = 1;
|
||||
while (used.contains(candidate)) {
|
||||
suffix += 1;
|
||||
candidate = '$baseName$suffix';
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
void _rewriteRefs(dynamic value, String oldRef, String newRef) {
|
||||
if (value is Map) {
|
||||
for (final entry in value.entries.toList()) {
|
||||
if (entry.key.toString() == r'$ref' && entry.value == oldRef) {
|
||||
value[entry.key] = newRef;
|
||||
} else {
|
||||
_rewriteRefs(entry.value, oldRef, newRef);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
_rewriteRefs(item, oldRef, newRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _structurallyEqual(dynamic left, dynamic right) {
|
||||
return jsonEncode(_canonicalize(left)) == jsonEncode(_canonicalize(right));
|
||||
}
|
||||
|
||||
dynamic _canonicalize(dynamic value) {
|
||||
if (value is Map) {
|
||||
final keys = value.keys.map((key) => key.toString()).toList()..sort();
|
||||
return <String, dynamic>{
|
||||
for (final key in keys) key: _canonicalize(value[key]),
|
||||
};
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(_canonicalize).toList(growable: false);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _deepCopyMap(Map<String, dynamic> value) {
|
||||
return _deepCopy(value) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
dynamic _deepCopy(dynamic value) {
|
||||
if (value is Map) {
|
||||
return value.map(
|
||||
(key, dynamic item) => MapEntry(key.toString(), _deepCopy(item)),
|
||||
);
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(_deepCopy).toList(growable: true);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String _escapePointerSegment(String value) {
|
||||
return value.replaceAll('~', '~0').replaceAll('/', '~1');
|
||||
}
|
||||
}
|
||||
+2337
-170
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,539 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../diagnostic/diagnostic.dart';
|
||||
import '../util/utils.dart';
|
||||
|
||||
/// A root OpenAPI document with normalized references and dependency metadata.
|
||||
class ResolvedOpenApiSource {
|
||||
/// Creates a resolved source result.
|
||||
const ResolvedOpenApiSource({
|
||||
required this.source,
|
||||
required this.document,
|
||||
required this.dependencies,
|
||||
});
|
||||
|
||||
/// Canonical path or URL of the root source.
|
||||
final String source;
|
||||
|
||||
/// Resolved root document with external components bundled locally.
|
||||
final Map<String, dynamic> document;
|
||||
|
||||
/// Canonical external documents loaded while resolving references.
|
||||
final List<String> dependencies;
|
||||
}
|
||||
|
||||
/// Loads OpenAPI documents and resolves local and external `$ref` values.
|
||||
class OpenApiReferenceResolver {
|
||||
/// Creates a resolver that reports issues to [diagnostics].
|
||||
OpenApiReferenceResolver(this.diagnostics);
|
||||
|
||||
/// Destination for loading and reference-resolution diagnostics.
|
||||
final KoiDiagnosticBag diagnostics;
|
||||
|
||||
final Map<String, Map<String, dynamic>> _documentCache =
|
||||
<String, Map<String, dynamic>>{};
|
||||
final Set<String> _loadedDependencies = <String>{};
|
||||
final Map<String, String> _bundledRefs = <String, String>{};
|
||||
final Map<String, Map<String, dynamic>> _bundledComponents =
|
||||
<String, Map<String, dynamic>>{};
|
||||
final Set<String> _activeInlineRefs = <String>{};
|
||||
|
||||
late String _rootSource;
|
||||
late Map<String, dynamic> _rootDocument;
|
||||
|
||||
/// Loads [source], resolves its references, and returns dependency metadata.
|
||||
Future<ResolvedOpenApiSource> resolve(String source) async {
|
||||
_rootSource = _canonicalSource(source);
|
||||
_loadedDependencies
|
||||
..clear()
|
||||
..add(_rootSource);
|
||||
_bundledRefs.clear();
|
||||
_bundledComponents.clear();
|
||||
_activeInlineRefs.clear();
|
||||
|
||||
final rawDocument = await _loadDocument(_rootSource);
|
||||
_rootDocument = _deepCopyMap(rawDocument);
|
||||
final resolvedValue = await _resolveNode(
|
||||
_rootDocument,
|
||||
baseSource: _rootSource,
|
||||
location: r'$',
|
||||
);
|
||||
final resolvedDocument = resolvedValue is Map
|
||||
? resolvedValue.cast<String, dynamic>()
|
||||
: <String, dynamic>{};
|
||||
_mergeBundledComponents(resolvedDocument);
|
||||
|
||||
return ResolvedOpenApiSource(
|
||||
source: _rootSource,
|
||||
document: resolvedDocument,
|
||||
dependencies:
|
||||
_loadedDependencies
|
||||
.where((dependency) => dependency != _rootSource)
|
||||
.toList(growable: false)
|
||||
..sort(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _resolveNode(
|
||||
dynamic value, {
|
||||
required String baseSource,
|
||||
required String location,
|
||||
}) async {
|
||||
if (value is List) {
|
||||
final result = <dynamic>[];
|
||||
for (var index = 0; index < value.length; index += 1) {
|
||||
result.add(
|
||||
await _resolveNode(
|
||||
value[index],
|
||||
baseSource: baseSource,
|
||||
location: '$location[$index]',
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (value is! Map) {
|
||||
return value;
|
||||
}
|
||||
|
||||
final map = value.cast<String, dynamic>();
|
||||
final ref = map[r'$ref'];
|
||||
if (ref is String) {
|
||||
final resolvedRef = await _resolveReference(
|
||||
ref,
|
||||
baseSource: baseSource,
|
||||
location: '$location.\$ref',
|
||||
);
|
||||
final siblings = <String, dynamic>{};
|
||||
for (final entry in map.entries.toList(growable: false)) {
|
||||
if (entry.key == r'$ref') {
|
||||
continue;
|
||||
}
|
||||
siblings[entry.key] = await _resolveNode(
|
||||
entry.value,
|
||||
baseSource: baseSource,
|
||||
location: '$location.${entry.key}',
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedRef is Map) {
|
||||
return deepMergeMaps(resolvedRef.cast<String, dynamic>(), siblings);
|
||||
}
|
||||
if (siblings.isNotEmpty) {
|
||||
diagnostics.error(
|
||||
'openapi.ref_siblings_on_non_object',
|
||||
'Reference `$ref` resolves to a non-object value, so sibling fields '
|
||||
'cannot be merged.',
|
||||
source: baseSource,
|
||||
location: location,
|
||||
);
|
||||
}
|
||||
return resolvedRef;
|
||||
}
|
||||
|
||||
final result = <String, dynamic>{};
|
||||
for (final entry in map.entries.toList(growable: false)) {
|
||||
result[entry.key] = await _resolveNode(
|
||||
entry.value,
|
||||
baseSource: baseSource,
|
||||
location: '$location.${entry.key}',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<dynamic> _resolveReference(
|
||||
String ref, {
|
||||
required String baseSource,
|
||||
required String location,
|
||||
}) async {
|
||||
final target = _resolveLocation(baseSource, ref);
|
||||
if (target == null) {
|
||||
diagnostics.error(
|
||||
'openapi.invalid_ref',
|
||||
'Unable to resolve reference URI `$ref`.',
|
||||
source: baseSource,
|
||||
location: location,
|
||||
);
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
if (target.source == _rootSource) {
|
||||
if (target.fragment.isEmpty) {
|
||||
diagnostics.error(
|
||||
'openapi.root_document_ref_not_supported',
|
||||
'Referencing an entire root OpenAPI document is not supported.',
|
||||
source: baseSource,
|
||||
location: location,
|
||||
);
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
return <String, dynamic>{r'$ref': '#${target.fragment}'};
|
||||
}
|
||||
|
||||
_loadedDependencies.add(target.source);
|
||||
final component = _componentPointer(target.fragment);
|
||||
if (component != null) {
|
||||
return _bundleExternalComponent(
|
||||
target,
|
||||
component: component,
|
||||
location: location,
|
||||
);
|
||||
}
|
||||
|
||||
final absoluteRef = '${target.source}#${target.fragment}';
|
||||
if (!_activeInlineRefs.add(absoluteRef)) {
|
||||
diagnostics.error(
|
||||
'openapi.unrepresentable_external_ref_cycle',
|
||||
'External cyclic reference `$absoluteRef` does not target a component '
|
||||
'and cannot be inlined safely.',
|
||||
source: baseSource,
|
||||
location: location,
|
||||
);
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
try {
|
||||
final document = await _loadDocument(target.source);
|
||||
final targetValue = _resolveJsonPointer(document, target.fragment);
|
||||
if (targetValue == null) {
|
||||
diagnostics.error(
|
||||
'openapi.unresolved_external_ref',
|
||||
'Reference target `$ref` does not exist in the external document.',
|
||||
source: target.source,
|
||||
location: '#${target.fragment}',
|
||||
);
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
return _resolveNode(
|
||||
_deepCopy(targetValue),
|
||||
baseSource: target.source,
|
||||
location: '${target.source}#${target.fragment}',
|
||||
);
|
||||
} finally {
|
||||
_activeInlineRefs.remove(absoluteRef);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _bundleExternalComponent(
|
||||
_ReferenceTarget target, {
|
||||
required _ComponentPointer component,
|
||||
required String location,
|
||||
}) async {
|
||||
final absoluteRef = '${target.source}#${target.fragment}';
|
||||
final existingRef = _bundledRefs[absoluteRef];
|
||||
if (existingRef != null) {
|
||||
return <String, dynamic>{r'$ref': existingRef};
|
||||
}
|
||||
|
||||
final localName = _allocateComponentName(
|
||||
component.kind,
|
||||
component.name,
|
||||
target.source,
|
||||
);
|
||||
final localRef =
|
||||
'#/components/${_escapePointerSegment(component.kind)}/'
|
||||
'${_escapePointerSegment(localName)}';
|
||||
_bundledRefs[absoluteRef] = localRef;
|
||||
|
||||
final group = _bundledComponents.putIfAbsent(
|
||||
component.kind,
|
||||
() => <String, dynamic>{},
|
||||
);
|
||||
group[localName] = <String, dynamic>{};
|
||||
|
||||
final document = await _loadDocument(target.source);
|
||||
final targetValue = _resolveJsonPointer(document, target.fragment);
|
||||
if (targetValue == null) {
|
||||
diagnostics.error(
|
||||
'openapi.unresolved_external_ref',
|
||||
'Component `${component.kind}/${component.name}` does not exist in '
|
||||
'the external document.',
|
||||
source: target.source,
|
||||
location: '#${target.fragment}',
|
||||
);
|
||||
return <String, dynamic>{r'$ref': localRef};
|
||||
}
|
||||
|
||||
final resolvedValue = await _resolveNode(
|
||||
_deepCopy(targetValue),
|
||||
baseSource: target.source,
|
||||
location: '${target.source}#${target.fragment}',
|
||||
);
|
||||
if (resolvedValue is! Map) {
|
||||
diagnostics.error(
|
||||
'openapi.invalid_external_component',
|
||||
'External component `${component.kind}/${component.name}` must be an '
|
||||
'object.',
|
||||
source: target.source,
|
||||
location: location,
|
||||
);
|
||||
} else {
|
||||
group[localName] = resolvedValue.cast<String, dynamic>();
|
||||
}
|
||||
return <String, dynamic>{r'$ref': localRef};
|
||||
}
|
||||
|
||||
String _allocateComponentName(
|
||||
String kind,
|
||||
String preferredName,
|
||||
String source,
|
||||
) {
|
||||
final rootComponents = _rootDocument['components'];
|
||||
final rootGroup = rootComponents is Map ? rootComponents[kind] : null;
|
||||
final existingNames = <String>{
|
||||
if (rootGroup is Map) ...rootGroup.keys.map((key) => key.toString()),
|
||||
...?_bundledComponents[kind]?.keys,
|
||||
};
|
||||
if (!existingNames.contains(preferredName)) {
|
||||
return preferredName;
|
||||
}
|
||||
|
||||
final sourceStem = _sourceStem(source);
|
||||
final baseName = '${sourceStem}_$preferredName';
|
||||
var candidate = baseName;
|
||||
var suffix = 1;
|
||||
while (existingNames.contains(candidate)) {
|
||||
suffix += 1;
|
||||
candidate = '$baseName$suffix';
|
||||
}
|
||||
diagnostics.warning(
|
||||
'openapi.external_component_renamed',
|
||||
'External component `$preferredName` conflicts with the root document '
|
||||
'and was renamed to `$candidate`.',
|
||||
source: source,
|
||||
location: '#/components/$kind/$preferredName',
|
||||
);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
void _mergeBundledComponents(Map<String, dynamic> document) {
|
||||
if (_bundledComponents.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final components = document.putIfAbsent(
|
||||
'components',
|
||||
() => <String, dynamic>{},
|
||||
);
|
||||
if (components is! Map) {
|
||||
diagnostics.error(
|
||||
'openapi.invalid_components',
|
||||
'Unable to add the external reference because the root document '
|
||||
'components value is not an object.',
|
||||
source: _rootSource,
|
||||
location: r'$.components',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final componentMap = components.cast<String, dynamic>();
|
||||
for (final entry in _bundledComponents.entries) {
|
||||
final rawGroup = componentMap.putIfAbsent(
|
||||
entry.key,
|
||||
() => <String, dynamic>{},
|
||||
);
|
||||
if (rawGroup is! Map) {
|
||||
diagnostics.error(
|
||||
'openapi.invalid_component_group',
|
||||
'components.${entry.key} must be an object.',
|
||||
source: _rootSource,
|
||||
location: '\$.components.${entry.key}',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
rawGroup.cast<String, dynamic>().addAll(entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _loadDocument(String source) async {
|
||||
final cached = _documentCache[source];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
final String content;
|
||||
if (isHttpUrl(source)) {
|
||||
final response = await http.get(Uri.parse(source));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
diagnostics.error(
|
||||
'openapi.source_http_error',
|
||||
'Failed to fetch the OpenAPI document: HTTP '
|
||||
'${response.statusCode}.',
|
||||
source: source,
|
||||
);
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
content = response.body;
|
||||
} else {
|
||||
final filePath = source.startsWith('file://')
|
||||
? Uri.parse(source).toFilePath()
|
||||
: source;
|
||||
content = await File(filePath).readAsString();
|
||||
}
|
||||
final document = decodeStructuredDocument(content);
|
||||
_documentCache[source] = document;
|
||||
return document;
|
||||
} on Object catch (error) {
|
||||
diagnostics.error(
|
||||
'openapi.source_load_failed',
|
||||
'Failed to read or parse the OpenAPI document: $error',
|
||||
source: source,
|
||||
);
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
|
||||
_ReferenceTarget? _resolveLocation(String baseSource, String ref) {
|
||||
final hashIndex = ref.indexOf('#');
|
||||
final rawPath = hashIndex < 0 ? ref : ref.substring(0, hashIndex);
|
||||
final fragment = hashIndex < 0 ? '' : ref.substring(hashIndex + 1);
|
||||
|
||||
try {
|
||||
if (rawPath.isEmpty) {
|
||||
return _ReferenceTarget(
|
||||
source: _canonicalSource(baseSource),
|
||||
fragment: fragment,
|
||||
);
|
||||
}
|
||||
if (isHttpUrl(rawPath) || rawPath.startsWith('file://')) {
|
||||
return _ReferenceTarget(
|
||||
source: _canonicalSource(rawPath),
|
||||
fragment: fragment,
|
||||
);
|
||||
}
|
||||
if (isHttpUrl(baseSource)) {
|
||||
final resolved = Uri.parse(baseSource).resolve(rawPath);
|
||||
return _ReferenceTarget(
|
||||
source: _canonicalSource(resolved.toString()),
|
||||
fragment: fragment,
|
||||
);
|
||||
}
|
||||
|
||||
final basePath = baseSource.startsWith('file://')
|
||||
? Uri.parse(baseSource).toFilePath()
|
||||
: baseSource;
|
||||
final targetPath = p.isAbsolute(rawPath)
|
||||
? rawPath
|
||||
: p.join(p.dirname(basePath), Uri.decodeComponent(rawPath));
|
||||
return _ReferenceTarget(
|
||||
source: p.normalize(p.absolute(targetPath)),
|
||||
fragment: fragment,
|
||||
);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _canonicalSource(String source) {
|
||||
if (isHttpUrl(source)) {
|
||||
final fragmentIndex = source.indexOf('#');
|
||||
final withoutFragment = fragmentIndex < 0
|
||||
? source
|
||||
: source.substring(0, fragmentIndex);
|
||||
return Uri.parse(withoutFragment).toString();
|
||||
}
|
||||
if (source.startsWith('file://')) {
|
||||
return Uri.file(p.normalize(Uri.parse(source).toFilePath())).toString();
|
||||
}
|
||||
return p.normalize(p.absolute(source));
|
||||
}
|
||||
|
||||
_ComponentPointer? _componentPointer(String fragment) {
|
||||
if (!fragment.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
final segments = fragment
|
||||
.substring(1)
|
||||
.split('/')
|
||||
.map(_decodePointerSegment)
|
||||
.toList(growable: false);
|
||||
if (segments.length != 3 || segments.first != 'components') {
|
||||
return null;
|
||||
}
|
||||
return _ComponentPointer(kind: segments[1], name: segments[2]);
|
||||
}
|
||||
|
||||
dynamic _resolveJsonPointer(Map<String, dynamic> document, String fragment) {
|
||||
if (fragment.isEmpty) {
|
||||
return document;
|
||||
}
|
||||
if (!fragment.startsWith('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
dynamic current = document;
|
||||
for (final rawSegment in fragment.substring(1).split('/')) {
|
||||
final segment = _decodePointerSegment(rawSegment);
|
||||
if (current is Map) {
|
||||
if (!current.containsKey(segment)) {
|
||||
return null;
|
||||
}
|
||||
current = current[segment];
|
||||
continue;
|
||||
}
|
||||
if (current is List) {
|
||||
final index = int.tryParse(segment);
|
||||
if (index == null || index < 0 || index >= current.length) {
|
||||
return null;
|
||||
}
|
||||
current = current[index];
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
String _decodePointerSegment(String value) {
|
||||
return Uri.decodeComponent(
|
||||
value.replaceAll('~1', '/').replaceAll('~0', '~'),
|
||||
);
|
||||
}
|
||||
|
||||
String _escapePointerSegment(String value) {
|
||||
return value.replaceAll('~', '~0').replaceAll('/', '~1');
|
||||
}
|
||||
|
||||
String _sourceStem(String source) {
|
||||
final path = isHttpUrl(source)
|
||||
? Uri.parse(source).path
|
||||
: source.startsWith('file://')
|
||||
? Uri.parse(source).toFilePath()
|
||||
: source;
|
||||
final stem = p.basenameWithoutExtension(path);
|
||||
return stem.isEmpty ? 'external' : toSnakeCase(stem);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _deepCopyMap(Map<String, dynamic> value) {
|
||||
return _deepCopy(value) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
dynamic _deepCopy(dynamic value) {
|
||||
if (value is Map) {
|
||||
return value.map(
|
||||
(key, dynamic item) => MapEntry(key.toString(), _deepCopy(item)),
|
||||
);
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(_deepCopy).toList(growable: true);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
class _ReferenceTarget {
|
||||
const _ReferenceTarget({required this.source, required this.fragment});
|
||||
|
||||
final String source;
|
||||
final String fragment;
|
||||
}
|
||||
|
||||
class _ComponentPointer {
|
||||
const _ComponentPointer({required this.kind, required this.name});
|
||||
|
||||
final String kind;
|
||||
final String name;
|
||||
}
|
||||
@@ -1,32 +1,64 @@
|
||||
enum BinaryTransportStyle { multipartFile, file, bytes, string }
|
||||
/// The Dart representation generated for multipart binary fields.
|
||||
enum BinaryTransportStyle {
|
||||
/// Uses Dio's `MultipartFile`.
|
||||
multipartFile,
|
||||
|
||||
/// Uses `dart:io`'s `File`.
|
||||
file,
|
||||
|
||||
/// Uses a byte list.
|
||||
bytes,
|
||||
|
||||
/// Uses a string value.
|
||||
string,
|
||||
}
|
||||
|
||||
/// A named set of compatibility defaults for generated Dart code.
|
||||
class GeneratorPreset {
|
||||
/// Creates a generator preset.
|
||||
const GeneratorPreset({
|
||||
required this.name,
|
||||
required this.multipartBinaryStyle,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
/// The stable configuration name of this preset.
|
||||
final String name;
|
||||
|
||||
/// The Dart representation used for multipart binary fields.
|
||||
final BinaryTransportStyle multipartBinaryStyle;
|
||||
|
||||
/// A human-readable English description of this preset.
|
||||
final String description;
|
||||
}
|
||||
|
||||
/// Resolves the built-in generator presets by name.
|
||||
class GeneratorPresetRegistry {
|
||||
GeneratorPresetRegistry._();
|
||||
|
||||
/// The Retrofit, Dio, and Freezed preset used by Koi Flutter projects.
|
||||
static const GeneratorPreset koiFlutter = GeneratorPreset(
|
||||
name: 'koi_flutter',
|
||||
multipartBinaryStyle: BinaryTransportStyle.multipartFile,
|
||||
description:
|
||||
'Koi Flutter preset,面向 Retrofit + Dio + Freezed 场景,'
|
||||
'multipart 二进制字段默认生成为 MultipartFile。',
|
||||
'Koi Flutter preset for Retrofit, Dio, and Freezed projects. '
|
||||
'Multipart binary fields use MultipartFile by default.',
|
||||
);
|
||||
|
||||
/// Resolves [rawName] to a built-in preset.
|
||||
///
|
||||
/// An omitted or empty name resolves to [koiFlutter]. Throws
|
||||
/// [ArgumentError] when the requested preset is unknown.
|
||||
static GeneratorPreset resolve(String? rawName) {
|
||||
final name = rawName?.trim();
|
||||
if (name == null || name.isEmpty || name == koiFlutter.name) {
|
||||
return koiFlutter;
|
||||
}
|
||||
|
||||
throw ArgumentError.value(name, 'generation.preset.name', '未知的 preset。');
|
||||
throw ArgumentError.value(
|
||||
name,
|
||||
'generation.preset.name',
|
||||
'Unknown preset.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+51
-2
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
/// Dart reserved words and built-in identifiers unsafe for generated names.
|
||||
const Set<String> dartKeywords = {
|
||||
'abstract',
|
||||
'as',
|
||||
@@ -88,6 +89,7 @@ const List<String> _knownSnakeCaseAcronyms = <String>[
|
||||
'uuid',
|
||||
];
|
||||
|
||||
/// Splits mixed-case, snake-case, kebab-case, and punctuated input into words.
|
||||
List<String> splitWords(String input) {
|
||||
final normalized = input
|
||||
.replaceAllMapped(
|
||||
@@ -111,6 +113,7 @@ List<String> splitWords(String input) {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// Converts [input] to a valid UpperCamelCase Dart identifier.
|
||||
String toPascalCase(String input) {
|
||||
final words = splitWords(input);
|
||||
if (words.isEmpty) {
|
||||
@@ -127,6 +130,7 @@ String toPascalCase(String input) {
|
||||
return sanitizeIdentifier(value, fallback: 'GeneratedValue');
|
||||
}
|
||||
|
||||
/// Converts [input] to a valid lowerCamelCase Dart identifier.
|
||||
String toCamelCase(String input) {
|
||||
final pascal = toPascalCase(input);
|
||||
if (pascal.isEmpty) {
|
||||
@@ -136,6 +140,7 @@ String toCamelCase(String input) {
|
||||
return sanitizeIdentifier(value, fallback: 'generatedValue');
|
||||
}
|
||||
|
||||
/// Converts [input] to a lowercase snake_case file or directory name.
|
||||
String toSnakeCase(String input) {
|
||||
final words = _mergeSnakeCaseAcronyms(splitWords(input));
|
||||
if (words.isEmpty) {
|
||||
@@ -187,6 +192,7 @@ class _SnakeCaseAcronymMatch {
|
||||
final int endIndex;
|
||||
}
|
||||
|
||||
/// Removes invalid Dart identifier characters and applies [fallback] as needed.
|
||||
String sanitizeIdentifier(String raw, {required String fallback}) {
|
||||
var value = raw.replaceAll(RegExp(r'[^A-Za-z0-9_]'), '');
|
||||
if (value.isEmpty) {
|
||||
@@ -201,6 +207,7 @@ String sanitizeIdentifier(String raw, {required String fallback}) {
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Escapes [value] for inclusion in a single-quoted generated Dart literal.
|
||||
String escapeDartString(String value) {
|
||||
return value
|
||||
.replaceAll('\\', r'\\')
|
||||
@@ -208,6 +215,7 @@ String escapeDartString(String value) {
|
||||
.replaceAll('\n', r'\n');
|
||||
}
|
||||
|
||||
/// Converts a YAML or ordinary map into a string-keyed Dart map.
|
||||
Map<String, dynamic> yamlToMap(dynamic value) {
|
||||
if (value is YamlMap) {
|
||||
return value.map(
|
||||
@@ -224,6 +232,7 @@ Map<String, dynamic> yamlToMap(dynamic value) {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
/// Recursively converts YAML collection nodes into ordinary Dart values.
|
||||
dynamic yamlValueToDynamic(dynamic value) {
|
||||
if (value is YamlMap || value is Map) {
|
||||
return yamlToMap(value);
|
||||
@@ -236,6 +245,7 @@ dynamic yamlValueToDynamic(dynamic value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Recursively overlays [override] values on a copy of [base].
|
||||
Map<String, dynamic> deepMergeMaps(
|
||||
Map<String, dynamic> base,
|
||||
Map<String, dynamic> override,
|
||||
@@ -253,10 +263,49 @@ Map<String, dynamic> deepMergeMaps(
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Whether [value] is an absolute HTTP or HTTPS URL.
|
||||
bool isHttpUrl(String value) {
|
||||
return value.startsWith('http://') || value.startsWith('https://');
|
||||
}
|
||||
|
||||
/// Matches an exact value, a shell-style glob (`*`, `?`, `**`), or a regular
|
||||
/// expression prefixed with `regex:`.
|
||||
bool matchesPattern(String value, String pattern) {
|
||||
if (pattern.startsWith('regex:')) {
|
||||
return RegExp(pattern.substring('regex:'.length)).hasMatch(value);
|
||||
}
|
||||
if (!pattern.contains('*') && !pattern.contains('?')) {
|
||||
return value == pattern;
|
||||
}
|
||||
|
||||
final buffer = StringBuffer('^');
|
||||
var index = 0;
|
||||
while (index < pattern.length) {
|
||||
final character = pattern[index];
|
||||
if (character == '*') {
|
||||
final isDouble = index + 1 < pattern.length && pattern[index + 1] == '*';
|
||||
buffer.write(isDouble ? '.*' : r'[^/]*');
|
||||
index += isDouble ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (character == '?') {
|
||||
buffer.write(r'[^/]');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
buffer.write(RegExp.escape(character));
|
||||
index += 1;
|
||||
}
|
||||
buffer.write(r'$');
|
||||
return RegExp(buffer.toString()).hasMatch(value);
|
||||
}
|
||||
|
||||
/// Whether [value] matches at least one entry in [patterns].
|
||||
bool matchesAnyPattern(String value, List<String>? patterns) {
|
||||
return patterns?.any((pattern) => matchesPattern(value, pattern)) ?? false;
|
||||
}
|
||||
|
||||
/// Decodes a JSON or YAML document whose root must be an object.
|
||||
Map<String, dynamic> decodeStructuredDocument(String content) {
|
||||
final trimmed = content.trimLeft();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
@@ -264,13 +313,13 @@ Map<String, dynamic> decodeStructuredDocument(String content) {
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
throw const FormatException('OpenAPI 根节点必须是对象。');
|
||||
throw const FormatException('The OpenAPI document root must be an object.');
|
||||
}
|
||||
|
||||
final yaml = loadYaml(content);
|
||||
final mapped = yamlToMap(yaml);
|
||||
if (mapped.isEmpty) {
|
||||
throw const FormatException('无法解析 OpenAPI 文档内容。');
|
||||
throw const FormatException('Unable to parse the OpenAPI document.');
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user