Complete OpenAPI 3.0/3.1 generation, diagnostics, lifecycle safety, enum and naming mappings, bilingual documentation, examples, and pub.dev release validation.
540 lines
16 KiB
Dart
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;
|
|
}
|