Skip to content
Open
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
21 changes: 18 additions & 3 deletions lib/space_gen.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<String, dynamic> openapiJson) {
return logging.runWithDefaultLogger(() => parser.parseOpenApi(openapiJson));
}
6 changes: 6 additions & 0 deletions lib/src/logger.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ R runWithLogger<R>(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>(R Function() fn) {
return runWithLogger(read(loggerRef, orElse: Logger.new), fn);
}

/// Set the global logger to verbose logging.
void setVerboseLogging() {
logger.level = Level.verbose;
Expand Down
12 changes: 12 additions & 0 deletions lib/src/naming.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
55 changes: 48 additions & 7 deletions lib/src/render.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -205,12 +225,10 @@ class GeneratorConfig {
final FileRendererBuilder fileRendererBuilder;
}

Future<void> loadAndRenderSpec(GeneratorConfig config) async {
final fs = config.outDir.fileSystem;
validatePackageName(config.packageName);

final templates = TemplateProvider.fromDirectory(config.templatesDir);

Future<RenderSpec> _loadRenderSpec(
SpecParserConfig config, {
required FileSystem fs,
}) async {
final cache = Cache(fs);
final specJson = await cache.load(config.specUrl);
final spec = parseOpenApi(specJson);
Expand All @@ -232,11 +250,34 @@ Future<void> 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<RenderSpec> loadRenderSpec(SpecParserConfig config) {
return runWithDefaultLogger(
() => _loadRenderSpec(config, fs: const LocalFileSystem()),
);
}

Future<void> 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,
Expand Down
6 changes: 4 additions & 2 deletions lib/src/render/file_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ export 'package:space_gen/src/render/formatting.dart'
show Formatter, RunProcess;

class _ModelCollector extends RenderTreeVisitor {
final Set<RenderSchema> schemas = {};
final _schemasByPointer = <Object, RenderSchema>{};

Set<RenderSchema> get schemas => _schemasByPointer.values.toSet();

@override
void visitSchema(RenderSchema schema) {
schemas.add(schema);
_schemasByPointer.putIfAbsent(schema.pointer, () => schema);
}
}

Expand Down
7 changes: 7 additions & 0 deletions lib/src/render/render_tree.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);
}
Expand All @@ -906,6 +909,7 @@ class RenderSpec {
required this.title,
required this.serverUrl,
required this.paths,
required this.componentSchemas,
required this.tagDefinitions,
});

Expand All @@ -918,6 +922,9 @@ class RenderSpec {
/// The paths of the spec.
final List<RenderPath> paths;

/// The top-level component schemas of the spec.
final Map<String, RenderSchema> componentSchemas;

/// The tag definitions of the spec.
final List<RenderTag> tagDefinitions;

Expand Down
3 changes: 3 additions & 0 deletions lib/src/render/tree_visitor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
7 changes: 7 additions & 0 deletions lib/src/resolver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);
}
Expand All @@ -1034,6 +1037,7 @@ class ResolvedSpec {
required this.serverUrl,
required this.title,
required this.paths,
required this.componentSchemas,
required this.tags,
});

Expand All @@ -1046,6 +1050,9 @@ class ResolvedSpec {
/// The paths of the spec.
final List<ResolvedPath> paths;

/// The top-level component schemas of the spec.
final Map<String, ResolvedSchema> componentSchemas;

/// The tags of the spec.
final List<ResolvedTag> tags;
}
Expand Down
98 changes: 98 additions & 0 deletions test/component_schemas_public_test.dart
Original file line number Diff line number Diff line change
@@ -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': <String, dynamic>{},
'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<SchemaRef>());
expect(components.schemas['Foo/Bar'], isA<SchemaRef>());
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<RenderSpec>());
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<RenderObject>());
expect(
collectAllSchemas(spec),
contains(spec.componentSchemas['Unreferenced']),
);
});
}