Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
include: package:very_good_analysis/analysis_options.yaml
analyzer:
exclude:
# gen_tests/* are standalone packages with their own pubspec, used as
# fixtures by tool/gen_tests.dart. Their deps aren't installed at the
# repo root, so analyzing them from here would produce false errors.
- gen_tests/**
linter:
rules:
# Disabled while I'm still finding the shape of the api.
Expand Down
1 change: 1 addition & 0 deletions cspell.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ ignorePaths:
words:
- spacetraders
- newtype
- newtypes
- eseidel
- cooldown
- petstore
Expand Down
14 changes: 9 additions & 5 deletions lib/src/render/file_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,8 @@ class FileRenderer {
);
}

bool rendersToSeparateFile(RenderSchema schema) => schema.createsNewType;
bool rendersToSeparateFile(RenderSchema schema) =>
schema.createsNewType && schema is! RenderRecursiveRef;

@visibleForTesting
Iterable<Import> importsForApi(Api api) {
Expand All @@ -363,8 +364,10 @@ class FileRenderer {
};

final apiSchemas = collectSchemasUnderApi(api);
final inlineSchemas = apiSchemas.where((s) => !rendersToSeparateFile(s));
final importedSchemas = apiSchemas.where(rendersToSeparateFile);
final inlineSchemas = apiSchemas.where((s) => !s.createsNewType);
// Every newtype (including RenderRecursiveRef, which points at one)
// lives in its own file and needs an import at the use site.
final importedSchemas = apiSchemas.where((s) => s.createsNewType);
final apiImports = importedSchemas
.map((s) => Import(modelPackageImport(this, s)))
.toList();
Expand Down Expand Up @@ -414,10 +417,11 @@ class FileRenderer {
Iterable<Import> importsForModel(RenderSchema schema) {
final referencedSchemas = collectSchemasUnderSchema(schema);
final localSchemas = referencedSchemas.where(
(s) => !rendersToSeparateFile(s),
(s) => !s.createsNewType,
);
// Every newtype (including RenderRecursiveRef) imports the target's file.
final importedSchemas = referencedSchemas
.where(rendersToSeparateFile)
.where((s) => s.createsNewType)
.toSet();
final referencedImports = importedSchemas
.map((s) => Import(modelPackageImport(this, s)))
Expand Down
86 changes: 86 additions & 0 deletions lib/src/render/render_tree.dart
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ class SpecResolver {

RenderSchema toRenderSchema(ResolvedSchema schema) {
switch (schema) {
case ResolvedRecursiveRef():
return RenderRecursiveRef(
common: schema.common,
targetPointer: schema.targetPointer,
);
case ResolvedEnum():
return RenderEnum(
common: schema.common,
Expand Down Expand Up @@ -1110,6 +1115,10 @@ class RenderPod extends RenderSchema {

@override
String get typeName {
// TODO(eseidel): Make RenderPod extensible.
// Right now we have this hard-coded list, but we should make it possible
// to register generators for various format types, e.g.
// https://spec.openapis.org/registry/format/ has many we don't implement.
return switch (type) {
PodType.boolean => 'bool',
PodType.dateTime => 'DateTime',
Expand Down Expand Up @@ -2520,3 +2529,80 @@ class RenderEmptyObject extends RenderNewType {
'nullableTypeName': nullableTypeName(context),
};
}

/// A cycle-break marker: appears where a $ref would otherwise recurse back
/// into a schema already being resolved (e.g. Node -> left/right -> Node).
/// At render time it behaves as a type reference — emits the target's class
/// name and the standard newtype toJson/fromJson calls — but never renders a
/// file of its own (the target is inlined elsewhere in the tree and renders
/// there).
///
/// The target is always an object-shaped newtype today: Object, OneOf,
/// AllOf, AnyOf, or EmptyObject. In theory a cycle could also go through a
/// top-level Array or Map newtype (their child schema can `$ref` back), but
/// space_gen doesn't render those as standalone classes yet — see the "Map
/// & Array newtype via explicitly named schema?" TODO in README.md. All the
/// currently-supported targets serialize as `Map<String, dynamic>` with a
/// `toJson()` / `fromJson(Map)` contract, so those assumptions are
/// hard-coded here. If top-level Array/Map newtypes ever ship, this class
/// needs to delegate `jsonStorageType`/expressions to the target instead of
/// hard-coding — probably via a pointer -> RenderSchema lookup on the
/// renderer.
class RenderRecursiveRef extends RenderSchema {
const RenderRecursiveRef({
required super.common,
required this.targetPointer,
}) : super(createsNewType: true);

final JsonPointer targetPointer;

@override
dynamic get defaultValue => null;

@override
bool get defaultCanConstConstruct => false;

@override
bool get shouldCallToJson => true;

@override
String get typeName => camelFromSnake(snakeName);

@override
String jsonStorageType({required bool isNullable}) =>
isNullable ? 'Map<String, dynamic>?' : 'Map<String, dynamic>';

@override
String toJsonExpression(
String dartName,
SchemaRenderer context, {
required bool dartIsNullable,
}) {
final nameCall = dartIsNullable ? '$dartName?' : dartName;
return '$nameCall.toJson()';
}

@override
String fromJsonExpression(
String jsonValue,
SchemaRenderer context, {
required bool jsonIsNullable,
required bool dartIsNullable,
}) {
final jsonType = jsonStorageType(isNullable: jsonIsNullable);
final jsonMethod = jsonIsNullable ? 'maybeFromJson' : 'fromJson';
final orDefault = orDefaultExpression(
context: context,
jsonIsNullable: jsonIsNullable,
dartIsNullable: dartIsNullable,
);
return '$typeName.$jsonMethod($jsonValue as $jsonType)$orDefault';
}

@override
Map<String, dynamic> toTemplateContext(SchemaRenderer context) =>
throw UnimplementedError('RenderRecursiveRef does not render a template');

@override
List<Object?> get props => [super.props, targetPointer];
}
1 change: 1 addition & 0 deletions lib/src/render/tree_visitor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class RenderTreeWalker {
}
case RenderMap():
walkSchema(schema.valueSchema);
case RenderRecursiveRef():
case RenderEnum():
case RenderString():
case RenderInteger():
Expand Down
63 changes: 61 additions & 2 deletions lib/src/resolver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ class ResolveContext {
required this.globalSecurityRequirements,
required this.securitySchemes,
this.nameOverrides = const {},
});
Set<JsonPointer>? resolvingStack,
}) : resolvingStack = resolvingStack ?? {};

/// Used for cases where we need a ResolveContext, but don't actually
/// plan to look up any objects in the registry.
Expand All @@ -35,9 +36,11 @@ class ResolveContext {
this.globalSecurityRequirements = const [],
List<SecurityScheme>? securitySchemes,
this.nameOverrides = const {},
Set<JsonPointer>? resolvingStack,
}) : specUrl = specUrl ?? Uri.parse('https://example.com'),
refRegistry = refRegistry ?? RefRegistry(),
securitySchemes = securitySchemes ?? [];
securitySchemes = securitySchemes ?? [],
resolvingStack = resolvingStack ?? {};

/// The spec url of the spec.
final Uri specUrl;
Expand All @@ -55,6 +58,11 @@ class ResolveContext {
/// Only contains names that actually changed due to collisions
final Map<JsonPointer, String> nameOverrides;

/// Stack of schemas currently being resolved, used to break $ref cycles
/// (e.g. `Node -> left/right -> Node`). When a cycle is detected the
/// resolver emits a [ResolvedRecursiveRef] instead of recursing.
final Set<JsonPointer> resolvingStack;

CommonProperties resolveCommonProperties(CommonProperties common) {
final resolvedName = getResolvedName(common.pointer, common.snakeName);
if (resolvedName != common.snakeName) {
Expand Down Expand Up @@ -119,6 +127,36 @@ ResolvedSchema resolveSchemaRef(SchemaRef ref, ResolveContext context) {
// Schema snake_name might change due to collisions, get resolved name.
final resolvedCommon = context.resolveCommonProperties(schema.common);

// Only ref-through-a-newtype can cycle (pod/array/map leaves can't $ref
// back up to themselves). For non-cyclic refs we keep inlining the
// resolved target as before — preserving existing tests and keeping
// ResolvedRecursiveRef strictly a cycle-break marker.
if (createsNewType && ref.ref != null) {
final targetPointer = schema.pointer;
if (context.resolvingStack.contains(targetPointer)) {
return ResolvedRecursiveRef(
common: resolvedCommon,
targetPointer: targetPointer,
);
}
context.resolvingStack.add(targetPointer);
try {
return _resolveSchemaFully(schema, resolvedCommon, context);
} finally {
context.resolvingStack.remove(targetPointer);
}
}

return _resolveSchemaFully(schema, resolvedCommon, context);
}

ResolvedSchema _resolveSchemaFully(
Schema schema,
CommonProperties resolvedCommon,
ResolveContext context,
) {
final createsNewType = shouldCreateNewType(schema);

if (schema is SchemaObject) {
assert(createsNewType, 'SchemaObject should create a new type');
return ResolvedObject(
Expand Down Expand Up @@ -1267,3 +1305,24 @@ class ResolvedEmptyObject extends ResolvedSchema {
return ResolvedEmptyObject(common: common ?? this.common);
}
}

class ResolvedRecursiveRef extends ResolvedSchema {
const ResolvedRecursiveRef({
required super.common,
required this.targetPointer,
}) : super(createsNewType: false);

/// The pointer to the referenced schema in the registry
final JsonPointer targetPointer;

@override
ResolvedRecursiveRef copyWith({CommonProperties? common}) {
return ResolvedRecursiveRef(
common: common ?? this.common,
targetPointer: targetPointer,
);
}

@override
List<Object?> get props => [super.props, targetPointer];
}
115 changes: 115 additions & 0 deletions test/render/file_renderer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,121 @@ void main() {
]),
);
});

test('self-referential schema generates a recursive class', () async {
final spec = {
'openapi': '3.1.0',
'info': {'title': 'Tree', 'version': '1.0.0'},
'servers': [
{'url': 'https://example.com'},
],
'paths': {
'/root': {
'get': {
'responses': {
'200': {
'description': 'OK',
'content': {
'application/json': {
'schema': {r'$ref': '#/components/schemas/Node'},
},
},
},
},
},
},
},
'components': {
'schemas': {
'Node': {
'type': 'object',
'properties': {
'value': {'type': 'string'},
'left': {r'$ref': '#/components/schemas/Node'},
'right': {r'$ref': '#/components/schemas/Node'},
},
},
},
},
};
final fs = MemoryFileSystem.test();
final out = fs.directory('tree');

await renderToDirectory(spec: spec, outDir: out);

// Only one file for Node — the cycle-break refs don't emit their own.
expect(out.childDirectory('lib/model'), hasFiles(['node.dart']));

final node = out.childFile('lib/model/node.dart').readAsStringSync();
// Recursive fields typed as Node?.
expect(node, contains('final Node? left;'));
expect(node, contains('final Node? right;'));
// toJson recurses via the standard newtype convention.
expect(node, contains('left?.toJson()'));
expect(node, contains('right?.toJson()'));
// fromJson recurses via Node.maybeFromJson (nullable ref).
expect(node, contains('Node.maybeFromJson'));
});

test('mutually-recursive schemas both generate', () async {
// Foo -> bar: Bar -> foo: Foo forms a cycle going through two types.
final spec = {
'openapi': '3.1.0',
'info': {'title': 'Mutual', 'version': '1.0.0'},
'servers': [
{'url': 'https://example.com'},
],
'paths': {
'/root': {
'get': {
'operationId': 'getRoot',
'responses': {
'200': {
'description': 'OK',
'content': {
'application/json': {
'schema': {r'$ref': '#/components/schemas/Foo'},
},
},
},
},
},
},
},
'components': {
'schemas': {
'Foo': {
'type': 'object',
'properties': {
'bar': {r'$ref': '#/components/schemas/Bar'},
},
},
'Bar': {
'type': 'object',
'properties': {
'foo': {r'$ref': '#/components/schemas/Foo'},
},
},
},
},
};
final fs = MemoryFileSystem.test();
final out = fs.directory('mutual');

await renderToDirectory(spec: spec, outDir: out);

expect(
out.childDirectory('lib/model'),
hasFiles(['foo.dart', 'bar.dart']),
);
final foo = out.childFile('lib/model/foo.dart').readAsStringSync();
final bar = out.childFile('lib/model/bar.dart').readAsStringSync();
// Each file references the other's class and imports its model file.
expect(foo, contains('final Bar? bar;'));
expect(foo, contains("import 'package:mutual/model/bar.dart'"));
expect(bar, contains('final Foo? foo;'));
expect(bar, contains("import 'package:mutual/model/foo.dart'"));
});
});

group('Formatter', () {
Expand Down
Loading
Loading