Skip to content
This repository was archived by the owner on Apr 14, 2026. It is now read-only.

Commit fe4f048

Browse files
authored
Merge pull request #8 from oapi-codegen/fix/issue-3-schema-filtering-external-refs
Fix schema filtering, external ApplyDefaults, and primitive component…
2 parents 97b43ac + afb7dde commit fe4f048

20 files changed

Lines changed: 867 additions & 39 deletions

File tree

experimental/internal/codegen/clientgen_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func TestClientGenerator(t *testing.T) {
2020

2121
// Gather schemas to build schema index
2222
contentTypeMatcher := NewContentTypeMatcher(DefaultContentTypes())
23-
schemas, err := GatherSchemas(doc, contentTypeMatcher)
23+
schemas, err := GatherSchemas(doc, contentTypeMatcher, OutputOptions{})
2424
require.NoError(t, err, "Failed to gather schemas")
2525

2626
// Compute names for schemas
@@ -90,7 +90,7 @@ func TestClientGenerator_FormEncoded(t *testing.T) {
9090
require.NoError(t, err, "Failed to parse comprehensive spec")
9191

9292
contentTypeMatcher := NewContentTypeMatcher(DefaultContentTypes())
93-
schemas, err := GatherSchemas(doc, contentTypeMatcher)
93+
schemas, err := GatherSchemas(doc, contentTypeMatcher, OutputOptions{})
9494
require.NoError(t, err, "Failed to gather schemas")
9595

9696
converter := NewNameConverter(NameMangling{}, NameSubstitutions{})

experimental/internal/codegen/codegen.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,22 @@ func Generate(doc libopenapi.Document, specData []byte, cfg Configuration) (stri
2626
// Create content type short namer for friendly type names
2727
contentTypeNamer := NewContentTypeShortNamer(cfg.ContentTypeShortNames)
2828

29-
// Pass 1: Gather all schemas that need types
30-
schemas, err := GatherSchemas(doc, contentTypeMatcher)
29+
// Pass 1: Gather all schemas that need types.
30+
// Operation filters (include/exclude tags, operation IDs) are applied during
31+
// gathering so that schemas from excluded operations are never collected.
32+
schemas, err := GatherSchemas(doc, contentTypeMatcher, cfg.OutputOptions)
3133
if err != nil {
3234
return "", fmt.Errorf("gathering schemas: %w", err)
3335
}
3436

35-
// Filter excluded schemas
37+
// Filter explicitly excluded schemas
3638
schemas = FilterSchemasByName(schemas, cfg.OutputOptions.ExcludeSchemas)
3739

40+
// Optionally prune component schemas that aren't referenced by any other schema
41+
if cfg.OutputOptions.PruneUnreferencedSchemas {
42+
schemas = PruneUnreferencedSchemas(schemas)
43+
}
44+
3845
// Pass 2: Compute names for all schemas
3946
converter := NewNameConverter(cfg.NameMangling, cfg.NameSubstitutions)
4047
ComputeSchemaNames(schemas, converter, contentTypeNamer)
@@ -492,8 +499,11 @@ func generateStructType(gen *TypeGenerator, desc *SchemaDescriptor) string {
492499
code := structCode + "\n" + marshalCode + "\n" + unmarshalCode
493500

494501
// Generate ApplyDefaults method if needed
495-
if applyDefaults := GenerateApplyDefaults(desc.ShortName, fields); applyDefaults != "" {
502+
if applyDefaults, needsReflect := GenerateApplyDefaults(desc.ShortName, fields); applyDefaults != "" {
496503
code += "\n" + applyDefaults
504+
if needsReflect {
505+
gen.AddImport("reflect")
506+
}
497507
}
498508

499509
return code
@@ -502,8 +512,11 @@ func generateStructType(gen *TypeGenerator, desc *SchemaDescriptor) string {
502512
code := GenerateStruct(desc.ShortName, fields, doc, gen.TagGenerator())
503513

504514
// Generate ApplyDefaults method if needed
505-
if applyDefaults := GenerateApplyDefaults(desc.ShortName, fields); applyDefaults != "" {
515+
if applyDefaults, needsReflect := GenerateApplyDefaults(desc.ShortName, fields); applyDefaults != "" {
506516
code += "\n" + applyDefaults
517+
if needsReflect {
518+
gen.AddImport("reflect")
519+
}
507520
}
508521

509522
return code
@@ -716,8 +729,11 @@ func generateAllOfType(gen *TypeGenerator, desc *SchemaDescriptor) string {
716729
}
717730

718731
// Generate ApplyDefaults method if needed
719-
if applyDefaults := GenerateApplyDefaults(desc.ShortName, finalFields); applyDefaults != "" {
732+
if applyDefaults, needsReflect := GenerateApplyDefaults(desc.ShortName, finalFields); applyDefaults != "" {
720733
code += "\n" + applyDefaults
734+
if needsReflect {
735+
gen.AddImport("reflect")
736+
}
721737
}
722738

723739
return code

experimental/internal/codegen/configuration.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ type OutputOptions struct {
5252
ExcludeOperationIDs []string `yaml:"exclude-operation-ids,omitempty"`
5353
// ExcludeSchemas excludes schemas with the given names from generation. Ignored when empty.
5454
ExcludeSchemas []string `yaml:"exclude-schemas,omitempty"`
55+
// PruneUnreferencedSchemas removes component schemas that are not $ref'd by any other
56+
// gathered schema. When combined with tag/operation filtering, this effectively removes
57+
// schemas that are only used by excluded operations.
58+
PruneUnreferencedSchemas bool `yaml:"prune-unreferenced-schemas,omitempty"`
5559
}
5660

5761
// ModelsPackage specifies an external package containing the model types.

experimental/internal/codegen/filter.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,66 @@ func FilterSchemasByName(schemas []*SchemaDescriptor, excludeNames []string) []*
6363
return result
6464
}
6565

66+
// PruneUnreferencedSchemas removes component schemas that are not $ref'd by any
67+
// other gathered schema. This walks the entire schema descriptor tree, collects
68+
// all $ref paths, and removes component schemas whose path doesn't appear in
69+
// that set. Non-component schemas (inline path schemas, etc.) are always kept.
70+
func PruneUnreferencedSchemas(schemas []*SchemaDescriptor) []*SchemaDescriptor {
71+
// Collect all $ref paths from all schemas
72+
referenced := make(map[string]bool)
73+
for _, s := range schemas {
74+
collectRefsFromDescriptor(s, referenced)
75+
}
76+
77+
result := make([]*SchemaDescriptor, 0, len(schemas))
78+
for _, s := range schemas {
79+
// Always keep non-component schemas (inline path schemas, etc.)
80+
if !s.IsComponentSchema() {
81+
result = append(result, s)
82+
continue
83+
}
84+
85+
// Keep component schemas that are referenced by something
86+
if referenced[s.Path.String()] {
87+
result = append(result, s)
88+
}
89+
}
90+
return result
91+
}
92+
93+
// collectRefsFromDescriptor walks a schema descriptor tree and adds all
94+
// internal $ref paths to the referenced set.
95+
func collectRefsFromDescriptor(desc *SchemaDescriptor, referenced map[string]bool) {
96+
if desc == nil {
97+
return
98+
}
99+
100+
// If this descriptor has a $ref, record it
101+
if desc.Ref != "" {
102+
referenced[desc.Ref] = true
103+
}
104+
105+
// Walk children
106+
for _, child := range desc.Properties {
107+
collectRefsFromDescriptor(child, referenced)
108+
}
109+
if desc.Items != nil {
110+
collectRefsFromDescriptor(desc.Items, referenced)
111+
}
112+
for _, child := range desc.AllOf {
113+
collectRefsFromDescriptor(child, referenced)
114+
}
115+
for _, child := range desc.AnyOf {
116+
collectRefsFromDescriptor(child, referenced)
117+
}
118+
for _, child := range desc.OneOf {
119+
collectRefsFromDescriptor(child, referenced)
120+
}
121+
if desc.AdditionalProps != nil {
122+
collectRefsFromDescriptor(desc.AdditionalProps, referenced)
123+
}
124+
}
125+
66126
// operationHasTag returns true if the operation has any of the given tags.
67127
func operationHasTag(op *OperationDescriptor, tags map[string]bool) bool {
68128
if op == nil || op.Spec == nil {

experimental/internal/codegen/filter_test.go

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ func TestFilterSchemasByName(t *testing.T) {
195195
require.NoError(t, err)
196196

197197
matcher := NewContentTypeMatcher(DefaultContentTypes())
198-
schemas, err := GatherSchemas(doc, matcher)
198+
schemas, err := GatherSchemas(doc, matcher, OutputOptions{})
199199
require.NoError(t, err)
200200

201201
// Count component schemas
@@ -228,7 +228,7 @@ func TestFilterSchemasByName_Empty(t *testing.T) {
228228
require.NoError(t, err)
229229

230230
matcher := NewContentTypeMatcher(DefaultContentTypes())
231-
schemas, err := GatherSchemas(doc, matcher)
231+
schemas, err := GatherSchemas(doc, matcher, OutputOptions{})
232232
require.NoError(t, err)
233233

234234
filtered := FilterSchemasByName(schemas, nil)
@@ -300,6 +300,74 @@ func TestFilterIntegration_GenerateWithExcludeSchemas(t *testing.T) {
300300
assert.NotContains(t, code, "type Pet struct")
301301
}
302302

303+
// TestFilterIntegration_IncludeTagsFiltersSchemas verifies that when include-tags is used,
304+
// only schemas referenced by the included operations are generated.
305+
// This is the behavioral test for https://github.com/oapi-codegen/oapi-codegen-exp/issues/3
306+
func TestFilterIntegration_IncludeTagsFiltersSchemas(t *testing.T) {
307+
doc, err := libopenapi.NewDocument([]byte(filterTestSpec))
308+
require.NoError(t, err)
309+
310+
cfg := Configuration{
311+
PackageName: "testpkg",
312+
Generation: GenerationOptions{
313+
Client: true,
314+
},
315+
OutputOptions: OutputOptions{
316+
IncludeTags: []string{"users"},
317+
PruneUnreferencedSchemas: true,
318+
},
319+
}
320+
321+
code, err := Generate(doc, []byte(filterTestSpec), cfg)
322+
require.NoError(t, err)
323+
324+
t.Logf("Generated code:\n%s", code)
325+
326+
// Operations: only ListUsers should be included
327+
assert.Contains(t, code, "ListUsers(ctx context.Context")
328+
assert.NotContains(t, code, "ListPets(ctx context.Context")
329+
assert.NotContains(t, code, "GetSettings(ctx context.Context")
330+
331+
// Schemas: only User (used by listUsers) should be generated.
332+
// Pet and Settings are only used by excluded operations and should NOT be generated.
333+
assert.Contains(t, code, "type User struct")
334+
assert.NotContains(t, code, "type Pet struct", "Pet schema should not be generated when only 'users' tag is included")
335+
assert.NotContains(t, code, "type Settings struct", "Settings schema should not be generated when only 'users' tag is included")
336+
}
337+
338+
// TestFilterIntegration_ExcludeTagsFiltersSchemas verifies that when exclude-tags is used,
339+
// schemas only referenced by excluded operations are not generated.
340+
func TestFilterIntegration_ExcludeTagsFiltersSchemas(t *testing.T) {
341+
doc, err := libopenapi.NewDocument([]byte(filterTestSpec))
342+
require.NoError(t, err)
343+
344+
cfg := Configuration{
345+
PackageName: "testpkg",
346+
Generation: GenerationOptions{
347+
Client: true,
348+
},
349+
OutputOptions: OutputOptions{
350+
ExcludeTags: []string{"admin"},
351+
PruneUnreferencedSchemas: true,
352+
},
353+
}
354+
355+
code, err := Generate(doc, []byte(filterTestSpec), cfg)
356+
require.NoError(t, err)
357+
358+
t.Logf("Generated code:\n%s", code)
359+
360+
// Operations: users and pets should be included, admin should not
361+
assert.Contains(t, code, "ListUsers(ctx context.Context")
362+
assert.Contains(t, code, "ListPets(ctx context.Context")
363+
assert.NotContains(t, code, "GetSettings(ctx context.Context")
364+
365+
// Schemas: User and Pet should be generated, Settings (admin-only) should not
366+
assert.Contains(t, code, "type User struct")
367+
assert.Contains(t, code, "type Pet struct")
368+
assert.NotContains(t, code, "type Settings struct", "Settings schema should not be generated when 'admin' tag is excluded")
369+
}
370+
303371
func TestFilterIntegration_ServerWithIncludeTags(t *testing.T) {
304372
doc, err := libopenapi.NewDocument([]byte(filterTestSpec))
305373
require.NoError(t, err)

0 commit comments

Comments
 (0)