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:
mr.koi
2026-07-31 16:47:39 +08:00
parent ea93f09154
commit 454e6c51d7
56 changed files with 14506 additions and 491 deletions
+26 -4
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+147
View File
@@ -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';
}
}
File diff suppressed because it is too large Load Diff
+686
View File
@@ -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
View File
@@ -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
View File
@@ -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,
);
}
}
}
+268
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+539
View File
@@ -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;
}
+36 -4
View File
@@ -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
View File
@@ -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;
}