Files
koi_swagger_parser/lib/src/openapi/reference_resolver.dart
T
mr.koi 454e6c51d7 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.
2026-07-31 16:47:39 +08:00

540 lines
16 KiB
Dart

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;
}