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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user