From 9f2068a40a5799ce25a69a165d4bb52233153758 Mon Sep 17 00:00:00 2001 From: Josh Burton Date: Wed, 10 Jun 2026 10:15:56 +1200 Subject: [PATCH] feat: expose parser and render graph APIs Summary: - add parser-only loadRenderSpec entrypoint for custom renderers - expose component schema iteration and local schema lookup helpers - publish render graph types and traversal helpers for downstream rendering Rationale: - downstream generators can reuse SpaceGen parsing and graph construction without importing src/ or invoking the built-in template renderer - the public parser surface is narrowed to supported schema/component types Tests: - dart analyze - dart test Co-Authored-By: GPT-5 feat: expose component schema render roots Summary: - Add top-level component schemas to the resolved and rendered spec graph. - Include component schema roots in render tree traversal and public schema collection. - Export collectAllSchemas from the public package API. - Cover unreferenced component schemas through the public parser/render API. Rationale: - Custom renderers need to render named component schemas even when no API path references them. - Component roots are declarations, not typed use sites, so the naming pass keeps existing oneOf variant smooshing behavior intact. Constraint: Preserve existing FileRenderer behavior and oneOf naming semantics. Rejected: Only export collectAllSchemas | this would still leave unreferenced component schemas absent from the render graph. Rejected: Count component roots as ordinary schema references | this breaks exclusive-use variant smooshing. Confidence: high Scope-risk: moderate Tests: - dart analyze - dart test Co-Authored-By: GPT-5 --- lib/space_gen.dart | 21 +++++- lib/src/logger.dart | 6 ++ lib/src/naming.dart | 12 +++ lib/src/render.dart | 55 ++++++++++++-- lib/src/render/file_renderer.dart | 6 +- lib/src/render/render_tree.dart | 7 ++ lib/src/render/tree_visitor.dart | 3 + lib/src/resolver.dart | 7 ++ test/component_schemas_public_test.dart | 98 +++++++++++++++++++++++++ 9 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 test/component_schemas_public_test.dart diff --git a/lib/space_gen.dart b/lib/space_gen.dart index 49558903..d5011291 100644 --- a/lib/space_gen.dart +++ b/lib/space_gen.dart @@ -1,8 +1,18 @@ +import 'package:space_gen/src/logger.dart' as logging; +import 'package:space_gen/src/parse/spec.dart' show OpenApi; +import 'package:space_gen/src/parser.dart' as parser; + export 'src/cli.dart' show runCli; export 'src/logger.dart' show Logger, logger, runWithLogger, setVerboseLogging; +export 'src/parse/spec.dart'; export 'src/quirks.dart' show Quirks; export 'src/render.dart' - show FileRendererBuilder, GeneratorConfig, loadAndRenderSpec; + show + FileRendererBuilder, + GeneratorConfig, + SpecParserConfig, + loadAndRenderSpec, + loadRenderSpec; // Experimental: hooks for custom file layouts. Consumers subclass // [FileRenderer] in their own generator entrypoint and override the // `@protected` hooks — see `tool/gen_shorebird.dart` for an example. @@ -11,5 +21,10 @@ export 'src/render.dart' // subclass via `super(config)`, so their concrete types aren't part // of the exported surface. export 'src/render/file_renderer.dart' - show FileRenderer, FileRendererConfig, LayoutContext; -export 'src/render/render_tree.dart' show Api, RenderSchema, RenderSpec; + show FileRenderer, FileRendererConfig, LayoutContext, collectAllSchemas; +export 'src/render/render_tree.dart'; +export 'src/types.dart' show JsonPointer, Method, ParameterLocation, PodType; + +OpenApi parseOpenApi(Map openapiJson) { + return logging.runWithDefaultLogger(() => parser.parseOpenApi(openapiJson)); +} diff --git a/lib/src/logger.dart b/lib/src/logger.dart index c1ce57d6..64ea3e46 100644 --- a/lib/src/logger.dart +++ b/lib/src/logger.dart @@ -16,6 +16,12 @@ R runWithLogger(Logger logger, R Function() fn) { return runScoped(fn, values: {loggerRef.overrideWith(() => logger)}); } +/// Run [fn] with the current logger, or a default [Logger] when no logger has +/// been scoped by the caller. +R runWithDefaultLogger(R Function() fn) { + return runWithLogger(read(loggerRef, orElse: Logger.new), fn); +} + /// Set the global logger to verbose logging. void setVerboseLogging() { logger.level = Level.verbose; diff --git a/lib/src/naming.dart b/lib/src/naming.dart index b8d403f6..e234e9ab 100644 --- a/lib/src/naming.dart +++ b/lib/src/naming.dart @@ -221,6 +221,9 @@ class _NameAssigner { } void visit(ResolvedSpec spec) { + for (final schema in spec.componentSchemas.values) { + _visitSchema(schema, _SlotKind.componentRoot); + } for (final path in spec.paths) { for (final op in path.operations) { visitOperation(op); @@ -521,6 +524,12 @@ class _NameAssigner { } case _SlotKind.other: _hasOtherUse.add(schema.pointer); + case _SlotKind.componentRoot: + // Component declarations are roots, not typed references. They need + // names, and their children still contribute typed uses, but the + // root itself must not disqualify oneOf variants from exclusive-use + // smooshing. + break; case _SlotKind.allOfMember: // AllOf members aren't typed Dart references — `ResolvedAllOf` // collapses into a synthesized merged `RenderObject` whose @@ -597,6 +606,9 @@ enum _SlotKind { /// request body / response. other, + /// Reached as a top-level component declaration. + componentRoot, + /// Reached as a member of an `allOf`. `ResolvedAllOf` collapses to /// a synthesized merged `RenderObject` at render time, so members /// contribute fields without emitting a typed reference to the diff --git a/lib/src/render.dart b/lib/src/render.dart index 773f7120..ba4915c0 100644 --- a/lib/src/render.dart +++ b/lib/src/render.dart @@ -1,6 +1,7 @@ import 'dart:collection'; import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:space_gen/src/loader.dart'; @@ -149,6 +150,25 @@ String describeSpecUrl(Uri url) { /// example). typedef FileRendererBuilder = FileRenderer Function(FileRendererConfig config); +/// Configuration for loading an OpenAPI document into SpaceGen's render tree +/// without invoking the built-in file/template renderer. +class SpecParserConfig { + const SpecParserConfig({ + required this.specUrl, + this.quirks = const Quirks(), + this.logSchemas = true, + }); + + /// The location of the OpenAPI spec to read. + final Uri specUrl; + + /// Flags that change the shape of the render graph (see [Quirks]). + final Quirks quirks; + + /// Whether to log each schema seen during resolution. + final bool logSchemas; +} + /// Top-level configuration for a single [loadAndRenderSpec] run. /// Holds every knob that controls the generation: where to read /// from, where to write to, which quirks/layout to use, and which @@ -205,12 +225,10 @@ class GeneratorConfig { final FileRendererBuilder fileRendererBuilder; } -Future loadAndRenderSpec(GeneratorConfig config) async { - final fs = config.outDir.fileSystem; - validatePackageName(config.packageName); - - final templates = TemplateProvider.fromDirectory(config.templatesDir); - +Future _loadRenderSpec( + SpecParserConfig config, { + required FileSystem fs, +}) async { final cache = Cache(fs); final specJson = await cache.load(config.specUrl); final spec = parseOpenApi(specJson); @@ -232,11 +250,34 @@ Future loadAndRenderSpec(GeneratorConfig config) async { final resolved = resolveSpec( spec, specUrl: config.specUrl, + logSchemas: config.logSchemas, refRegistry: refRegistry, ); final resolver = SpecResolver(config.quirks); // Convert the resolved spec into render objects. - final renderSpec = resolver.toRenderSpec(resolved); + return resolver.toRenderSpec(resolved); +} + +/// Load an OpenAPI document and build SpaceGen's render tree without writing +/// files or invoking templates. +Future loadRenderSpec(SpecParserConfig config) { + return runWithDefaultLogger( + () => _loadRenderSpec(config, fs: const LocalFileSystem()), + ); +} + +Future loadAndRenderSpec(GeneratorConfig config) async { + final fs = config.outDir.fileSystem; + final parserConfig = SpecParserConfig( + specUrl: config.specUrl, + quirks: config.quirks, + logSchemas: config.logSchemas, + ); + + validatePackageName(config.packageName); + final templates = TemplateProvider.fromDirectory(config.templatesDir); + final renderSpec = await _loadRenderSpec(parserConfig, fs: fs); + // SchemaRenderer is responsible for rendering schemas and APIs into strings. final schemaRenderer = SchemaRenderer( templates: templates, diff --git a/lib/src/render/file_renderer.dart b/lib/src/render/file_renderer.dart index 77d644be..2279506f 100644 --- a/lib/src/render/file_renderer.dart +++ b/lib/src/render/file_renderer.dart @@ -17,11 +17,13 @@ export 'package:space_gen/src/render/formatting.dart' show Formatter, RunProcess; class _ModelCollector extends RenderTreeVisitor { - final Set schemas = {}; + final _schemasByPointer = {}; + + Set get schemas => _schemasByPointer.values.toSet(); @override void visitSchema(RenderSchema schema) { - schemas.add(schema); + _schemasByPointer.putIfAbsent(schema.pointer, () => schema); } } diff --git a/lib/src/render/render_tree.dart b/lib/src/render/render_tree.dart index bcacc8ff..33a2ae0d 100644 --- a/lib/src/render/render_tree.dart +++ b/lib/src/render/render_tree.dart @@ -887,6 +887,9 @@ class SpecResolver { title: spec.title, serverUrl: spec.serverUrl, paths: spec.paths.map(toRenderPath).toList(), + componentSchemas: spec.componentSchemas.map( + (name, schema) => MapEntry(name, toRenderSchema(schema)), + ), tagDefinitions: spec.tags.map(toRenderTag).toList(), ); } @@ -906,6 +909,7 @@ class RenderSpec { required this.title, required this.serverUrl, required this.paths, + required this.componentSchemas, required this.tagDefinitions, }); @@ -918,6 +922,9 @@ class RenderSpec { /// The paths of the spec. final List paths; + /// The top-level component schemas of the spec. + final Map componentSchemas; + /// The tag definitions of the spec. final List tagDefinitions; diff --git a/lib/src/render/tree_visitor.dart b/lib/src/render/tree_visitor.dart index 2052fa6e..4d080c22 100644 --- a/lib/src/render/tree_visitor.dart +++ b/lib/src/render/tree_visitor.dart @@ -17,6 +17,9 @@ class RenderTreeWalker { final RenderTreeVisitor visitor; void walkRoot(RenderSpec spec) { + for (final schema in spec.componentSchemas.values) { + walkSchema(schema); + } for (final api in spec.apis) { walkApi(api); } diff --git a/lib/src/resolver.dart b/lib/src/resolver.dart index 1c042c89..76325e13 100644 --- a/lib/src/resolver.dart +++ b/lib/src/resolver.dart @@ -1015,6 +1015,9 @@ ResolvedSpec resolveSpec( title: spec.info.title, serverUrl: spec.serverUrl, paths: _resolvePaths(spec.paths, context), + componentSchemas: spec.components.schemas.map( + (name, schema) => MapEntry(name, resolveSchemaRef(schema, context)), + ), tags: spec.tags.map(_resolvedTag).toList(), ); } @@ -1034,6 +1037,7 @@ class ResolvedSpec { required this.serverUrl, required this.title, required this.paths, + required this.componentSchemas, required this.tags, }); @@ -1046,6 +1050,9 @@ class ResolvedSpec { /// The paths of the spec. final List paths; + /// The top-level component schemas of the spec. + final Map componentSchemas; + /// The tags of the spec. final List tags; } diff --git a/test/component_schemas_public_test.dart b/test/component_schemas_public_test.dart new file mode 100644 index 00000000..c95de16f --- /dev/null +++ b/test/component_schemas_public_test.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:space_gen/space_gen.dart'; +import 'package:test/test.dart'; + +void main() { + final specJson = { + 'openapi': '3.1.0', + 'info': { + 'title': 'Component schemas public API test', + 'description': 'Ignored metadata should not require a scoped logger.', + 'version': '1.0.0', + 'license': {'name': 'MIT'}, + }, + 'servers': [ + {'url': 'https://example.com'}, + ], + 'paths': {}, + 'components': { + 'schemas': { + 'User': { + 'type': 'object', + 'properties': { + 'pet': {r'$ref': '#/components/schemas/Pet'}, + 'escaped': {r'$ref': '#/components/schemas/Foo~1Bar'}, + }, + }, + 'Pet': {'type': 'string'}, + 'Foo/Bar': {'type': 'integer'}, + 'PetAlias': {r'$ref': '#/components/schemas/Pet'}, + 'Unreferenced': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + }, + }, + }, + }, + }; + + test('public API exposes parsed component schemas', () { + final spec = parseOpenApi(specJson); + + final components = spec.components; + expect(components.schemas.keys, [ + 'User', + 'Pet', + 'Foo/Bar', + 'PetAlias', + 'Unreferenced', + ]); + + final user = components.schemas['User']!.object! as SchemaObject; + expect( + user.properties['pet']!.ref!.uri.toString(), + '#/components/schemas/Pet', + ); + expect( + user.properties['escaped']!.ref!.uri.toString(), + '#/components/schemas/Foo~1Bar', + ); + + expect(components.schemas['Pet'], isA()); + expect(components.schemas['Foo/Bar'], isA()); + expect( + components.schemas['PetAlias']!.ref!.uri.toString(), + '#/components/schemas/Pet', + ); + }); + + test('loadRenderSpec builds a render tree without rendering files', () async { + final tempDir = await Directory.systemTemp.createTemp('space_gen_test_'); + addTearDown(() => tempDir.delete(recursive: true)); + final specFile = File('${tempDir.path}/openapi.json') + ..writeAsStringSync(jsonEncode(specJson)); + + final spec = await loadRenderSpec( + SpecParserConfig(specUrl: specFile.uri, logSchemas: false), + ); + + expect(spec, isA()); + expect(spec.title, 'Component schemas public API test'); + expect(spec.paths, isEmpty); + expect(spec.componentSchemas.keys, [ + 'User', + 'Pet', + 'Foo/Bar', + 'PetAlias', + 'Unreferenced', + ]); + expect(spec.componentSchemas['Unreferenced'], isA()); + expect( + collectAllSchemas(spec), + contains(spec.componentSchemas['Unreferenced']), + ); + }); +}