From 32778d6c8da4ff8821c48a81c23380bc975cb21a Mon Sep 17 00:00:00 2001 From: quobix Date: Thu, 21 May 2026 09:02:38 -0700 Subject: [PATCH] feat(renderer): add configurable work and output budgets for mock generation Introduce MockGenerationOptions to bound recursive depth, visited nodes, rendered properties, ref expansions, generated string bytes, regex repeat budgets, and approximate mock size. Route schema rendering through a new mockRenderContext that tracks budgets, detects cycles, and caches completed subtrees, and add RenderSchemaWithError plus ErrMockGenerationBudgetExceeded / MockGenerationBudgetError for callers that want budget enforcement. Extract string value generation into mock_value_renderer.go, tighten doc comments across the renderer, and cover the new behavior with mock_generation_options_test.go and mock_value_renderer_test.go. --- renderer/mock_generation_options_test.go | 1055 ++++++++++++++++++++++ renderer/mock_generator.go | 93 +- renderer/mock_generator_xml.go | 6 +- renderer/mock_render_context.go | 479 ++++++++++ renderer/mock_value_renderer.go | 154 ++++ renderer/mock_value_renderer_test.go | 36 + renderer/schema_renderer.go | 414 +++++---- renderer/schema_renderer_test.go | 2 +- 8 files changed, 2004 insertions(+), 235 deletions(-) create mode 100644 renderer/mock_generation_options_test.go create mode 100644 renderer/mock_render_context.go create mode 100644 renderer/mock_value_renderer.go create mode 100644 renderer/mock_value_renderer_test.go diff --git a/renderer/mock_generation_options_test.go b/renderer/mock_generation_options_test.go new file mode 100644 index 00000000..671a4676 --- /dev/null +++ b/renderer/mock_generation_options_test.go @@ -0,0 +1,1055 @@ +// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package renderer + +import ( + "encoding/json" + "errors" + "math/rand" + "strconv" + "testing" + + "github.com/pb33f/libopenapi" + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/libopenapi/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestNormalizeMockGenerationOptions(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + input MockGenerationOptions + expected MockGenerationOptions + }{ + "defaults": { + input: MockGenerationOptions{}, + expected: MockGenerationOptions{ + MaxPatternRepeatBudget: DefaultMaxPatternRepeatBudget, + MaxGeneratedStringBytes: DefaultMaxGeneratedStringBytes, + MaxMockDepth: DefaultMaxMockDepth, + MaxMockNodes: DefaultMaxMockNodes, + MaxMockProperties: DefaultMaxMockProperties, + MaxMockRefExpansions: DefaultMaxMockRefExpansions, + MaxMockBytes: DefaultMaxMockBytes, + }, + }, + "custom": { + input: MockGenerationOptions{ + MaxPatternRepeatBudget: 7, + MaxGeneratedStringBytes: 128, + MaxMockDepth: 8, + MaxMockNodes: 9, + MaxMockProperties: 10, + MaxMockRefExpansions: 11, + MaxMockBytes: 12, + }, + expected: MockGenerationOptions{ + MaxPatternRepeatBudget: 7, + MaxGeneratedStringBytes: 128, + MaxMockDepth: 8, + MaxMockNodes: 9, + MaxMockProperties: 10, + MaxMockRefExpansions: 11, + MaxMockBytes: 12, + }, + }, + "partial defaults": { + input: MockGenerationOptions{ + MaxPatternRepeatBudget: -1, + MaxGeneratedStringBytes: 256, + MaxMockDepth: -1, + MaxMockProperties: 10, + }, + expected: MockGenerationOptions{ + MaxPatternRepeatBudget: DefaultMaxPatternRepeatBudget, + MaxGeneratedStringBytes: 256, + MaxMockDepth: DefaultMaxMockDepth, + MaxMockNodes: DefaultMaxMockNodes, + MaxMockProperties: 10, + MaxMockRefExpansions: DefaultMaxMockRefExpansions, + MaxMockBytes: DefaultMaxMockBytes, + }, + }, + } + + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.expected, normalizeMockGenerationOptions(tc.input)) + }) + } +} + +func TestSchemaRenderer_EffectiveMockGenerationOptions_DefaultsNilRenderer(t *testing.T) { + t.Parallel() + + var renderer *SchemaRenderer + + assert.Equal(t, normalizeMockGenerationOptions(MockGenerationOptions{}), renderer.effectiveMockGenerationOptions()) +} + +func TestBoundedGeneratedStringRange(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + min int64 + max int64 + maxBytes int + wantMin int64 + wantMax int64 + }{ + "disabled": { + min: 3, + max: 10, + wantMin: 3, + wantMax: 10, + }, + "caps max": { + min: 3, + max: 100, + maxBytes: 12, + wantMin: 3, + wantMax: 12, + }, + "caps min and max": { + min: 100, + max: 200, + maxBytes: 12, + wantMin: 12, + wantMax: 12, + }, + "raises max to capped min": { + min: 10, + max: 4, + maxBytes: 8, + wantMin: 8, + wantMax: 8, + }, + } + + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + gotMin, gotMax := boundedGeneratedStringRange(tc.min, tc.max, tc.maxBytes) + assert.Equal(t, tc.wantMin, gotMin) + assert.Equal(t, tc.wantMax, gotMax) + }) + } +} + +func TestTruncateStringBytes(t *testing.T) { + t.Parallel() + + assert.Equal(t, "abcdef", truncateStringBytes("abcdef", 0)) + assert.Equal(t, "abc", truncateStringBytes("abcdef", 3)) + assert.Equal(t, "abc", truncateStringBytes("abc", 3)) + assert.Empty(t, truncateStringBytes("éclair", 1)) + assert.Equal(t, "é", truncateStringBytes("éclair", 2)) +} + +func TestSchemaRenderer_SetMockGenerationOptions_CapsGeneratedStringBytes(t *testing.T) { + t.Parallel() + + renderer := emptyDictionarySchemaRenderer(1) + renderer.SetMockGenerationOptions(MockGenerationOptions{MaxGeneratedStringBytes: 12}) + + value := renderStringSchema(t, renderer, `type: string +minLength: 100 +maxLength: 100`) + + assert.Len(t, value, 12) +} + +func TestSchemaRenderer_SetMockGenerationOptions_BoundsAWSARNPattern(t *testing.T) { + t.Parallel() + + renderer := emptyDictionarySchemaRenderer(1) + renderer.SetMockGenerationOptions(MockGenerationOptions{ + MaxPatternRepeatBudget: 2, + MaxGeneratedStringBytes: 128, + }) + + value := renderStringSchema(t, renderer, `type: string +pattern: 'arn:(aws|aws-us-gov|aws-cn|aws-iso|aws-iso-b):iam::[0-9]{12}:(role|role/service-role)/[\w+=,.@/-]{1,1000}' +maxLength: 1024`) + + assert.NotEmpty(t, value) + assert.LessOrEqual(t, len(value), 128) + assert.Contains(t, value, "arn:") +} + +func TestSchemaRenderer_SetMockGenerationOptions_UsesSchemaMaxLengthWhenSmallerThanRepeatBudget(t *testing.T) { + t.Parallel() + + renderer := emptyDictionarySchemaRenderer(1) + renderer.SetMockGenerationOptions(MockGenerationOptions{ + MaxPatternRepeatBudget: 32, + MaxGeneratedStringBytes: 128, + }) + + value := renderStringSchema(t, renderer, `type: string +pattern: '[a-z]{0,100}' +maxLength: 2`) + + assert.LessOrEqual(t, len(value), 2) +} + +func TestSchemaRenderer_SetMockGenerationOptions_InvalidPatternFallsBackToBoundedWord(t *testing.T) { + t.Parallel() + + renderer := emptyDictionarySchemaRenderer(1) + renderer.SetMockGenerationOptions(MockGenerationOptions{MaxGeneratedStringBytes: 8}) + + value := renderStringSchema(t, renderer, `type: string +pattern: '[' +minLength: 20 +maxLength: 20`) + + assert.Len(t, value, 8) +} + +func TestMockGenerator_SetMockGenerationOptions(t *testing.T) { + t.Parallel() + + fake := createFakeMock(`type: object +required: [arn] +properties: + arn: + type: string + pattern: 'arn:(aws|aws-us-gov|aws-cn|aws-iso|aws-iso-b):iam::[0-9]{12}:(role|role/service-role)/[\w+=,.@/-]{1,1000}' + maxLength: 1024`, nil, nil) + + mg := NewMockGenerator(JSON) + mg.SetMockGenerationOptions(MockGenerationOptions{ + MaxPatternRepeatBudget: 1, + MaxGeneratedStringBytes: 64, + }) + + mock, err := mg.GenerateMock(fake, "") + require.NoError(t, err) + + var payload map[string]string + require.NoError(t, json.Unmarshal(mock, &payload)) + assert.NotEmpty(t, payload["arn"]) + assert.LessOrEqual(t, len(payload["arn"]), 64) +} + +func TestMockGenerator_GenerationBudgetExceeded(t *testing.T) { + t.Parallel() + + fake := createFakeMock(`type: object +properties: + alpha: + type: string + beta: + type: string`, nil, nil) + + mg := NewMockGenerator(JSON) + mg.SetMockGenerationOptions(MockGenerationOptions{MaxMockProperties: 1}) + + mock, err := mg.GenerateMock(fake, "") + + require.Error(t, err) + assert.True(t, errors.Is(err, ErrMockGenerationBudgetExceeded)) + assert.Nil(t, mock) +} + +func TestSchemaRenderer_RenderSchemaWithErrorBudgetExceeded(t *testing.T) { + t.Parallel() + + renderer := emptyDictionarySchemaRenderer(1) + schema := schemaWithStringProperties(DefaultMaxMockProperties + 1) + + rendered, err := renderer.RenderSchemaWithError(schema) + + require.Error(t, err) + assert.True(t, errors.Is(err, ErrMockGenerationBudgetExceeded)) + assert.Nil(t, rendered) +} + +func TestSchemaRenderer_RenderSchemaBestEffortPastBudget(t *testing.T) { + t.Parallel() + + renderer := emptyDictionarySchemaRenderer(1) + schema := schemaWithStringProperties(DefaultMaxMockProperties + 1) + + rendered := renderer.RenderSchema(schema) + + require.NotNil(t, rendered) + payload, ok := rendered.(map[string]any) + require.True(t, ok) + assert.Len(t, payload, DefaultMaxMockProperties+1) +} + +func TestMockGenerator_RenderContextUsesCompletedRefCache(t *testing.T) { + t.Parallel() + + schema := componentSchemaForMockTest(t, "Root", `openapi: 3.1.0 +info: + title: cache + version: 1.0.0 +paths: {} +components: + schemas: + Root: + type: object + properties: + leafA: + $ref: '#/components/schemas/Leaf' + leafB: + $ref: '#/components/schemas/Leaf' + Leaf: + type: object + properties: + name: + type: string`) + + mg := NewMockGenerator(JSON) + mg.SetSeed(1) + mg.SetMockGenerationOptions(MockGenerationOptions{MaxMockRefExpansions: 1}) + + mock, err := mg.GenerateMock(schema, "") + + require.NoError(t, err) + var payload map[string]map[string]string + require.NoError(t, json.Unmarshal(mock, &payload)) + assert.NotEmpty(t, payload["leafA"]["name"]) + assert.NotEmpty(t, payload["leafB"]["name"]) +} + +func TestMockGenerator_RenderContextCacheSeparatesArrayItemsFromScalar(t *testing.T) { + t.Parallel() + + schema := componentSchemaForMockTest(t, "Root", `openapi: 3.1.0 +info: + title: cache-shape + version: 1.0.0 +paths: {} +components: + schemas: + Root: + type: object + properties: + list: + type: array + items: + $ref: '#/components/schemas/Code' + scalar: + $ref: '#/components/schemas/Code' + Code: + type: string + examples: + - one + - two`) + + mg := NewMockGenerator(JSON) + mg.SetSeed(1) + + mock, err := mg.GenerateMock(schema, "") + + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(mock, &payload)) + assert.Equal(t, []any{"one", "two"}, payload["list"]) + assert.Equal(t, "one", payload["scalar"]) +} + +func TestMockGenerator_RenderContextCopiesMutableCachedValues(t *testing.T) { + t.Parallel() + + schema := componentSchemaForMockTest(t, "Root", `openapi: 3.1.0 +info: + title: cache-copy + version: 1.0.0 +paths: {} +components: + schemas: + Root: + type: object + properties: + a: + $ref: '#/components/schemas/Foo' + b: + $ref: '#/components/schemas/Foo' + dependentSchemas: + a: + type: object + properties: + dependent: + type: string + enum: [only-a] + Foo: + type: object + properties: + base: + type: string + enum: [base]`) + + mg := NewMockGenerator(JSON) + mg.SetMockGenerationOptions(MockGenerationOptions{MaxMockRefExpansions: 1}) + + mock, err := mg.GenerateMock(schema, "") + + require.NoError(t, err) + var payload map[string]map[string]string + require.NoError(t, json.Unmarshal(mock, &payload)) + assert.Equal(t, "base", payload["a"]["base"]) + assert.Equal(t, "only-a", payload["a"]["dependent"]) + assert.Equal(t, "base", payload["b"]["base"]) + assert.NotContains(t, payload["b"], "dependent") +} + +func TestMockGenerator_RenderContextStopsActiveReferenceCycle(t *testing.T) { + t.Parallel() + + schema := componentSchemaForMockTest(t, "Node", `openapi: 3.1.0 +info: + title: cycle + version: 1.0.0 +paths: {} +components: + schemas: + Node: + type: object + properties: + child: + $ref: '#/components/schemas/Node' + label: + type: string`) + + mg := NewMockGenerator(JSON) + mg.SetSeed(1) + + mock, err := mg.GenerateMock(schema, "") + + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(mock, &payload)) + assert.NotContains(t, payload, "child") + assert.NotEmpty(t, payload["label"]) +} + +func TestMockGenerationBudgetErrorUnwrap(t *testing.T) { + t.Parallel() + + err := &MockGenerationBudgetError{Budget: "nodes", Limit: 1, Actual: 2} + + assert.ErrorIs(t, err, ErrMockGenerationBudgetExceeded) + assert.Equal(t, "mock generation budget exceeded: nodes budget exceeded: 2 > 1", err.Error()) +} + +func TestSchemaRenderer_RenderSchemaWithErrorEnforcesRaisedDepthBudget(t *testing.T) { + t.Parallel() + + limit := DefaultMaxMockDepth + 50 + renderer := emptyDictionarySchemaRenderer(1) + renderer.SetMockGenerationOptions(MockGenerationOptions{MaxMockDepth: limit}) + + rendered, err := renderer.RenderSchemaWithError(nestedObjectSchema(limit + 1)) + + require.Error(t, err) + assert.True(t, errors.Is(err, ErrMockGenerationBudgetExceeded)) + assert.Nil(t, rendered) +} + +func TestMockRenderContext_NilRendererAndSchemaKeyFallbacks(t *testing.T) { + t.Parallel() + + ctx := newMockRenderContext(nil) + require.NotNil(t, ctx.renderer) + + refSchema := &highbase.Schema{ParentProxy: highbase.CreateSchemaProxyRef("#/components/schemas/Thing")} + refKey, ok := ctx.schemaKey(refSchema) + require.True(t, ok) + assert.Equal(t, "#/components/schemas/Thing", refKey.ref) + + inlineSchema := &highbase.Schema{} + inlineKey, ok := ctx.schemaKey(inlineSchema) + require.True(t, ok) + assert.Same(t, inlineSchema, inlineKey.schema) +} + +func TestMockGenerator_RenderContextCoversScalarAndArrayBranches(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "date time": "type: string\nformat: date-time", + "date": "type: string\nformat: date", + "time": "type: string\nformat: time", + "email": "type: string\nformat: email", + "hostname": "type: string\nformat: hostname", + "ipv4": "type: string\nformat: ipv4", + "ipv6": "type: string\nformat: ipv6", + "uri": "type: string\nformat: uri", + "uri reference": "type: string\nformat: uri-reference", + "uuid": "type: string\nformat: uuid", + "byte": "type: string\nformat: byte", + "password": "type: string\nformat: password", + "binary": "type: string\nformat: binary", + "bigint string": "type: string\nformat: bigint", + "decimal string": "type: string\nformat: decimal", + "pattern": "type: string\npattern: '[a-z]{3}'", + "enum": "type: string\nenum: [one, two]", + "array": "type: array\nitems:\n type: string", + "float": "type: number\nformat: float", + "double": "type: number\nformat: double", + "int32": "type: integer\nformat: int32", + "bigint number": "type: bigint", + "decimal number": "type: decimal", + "number enum": "type: number\nenum: [1, 2]", + } + + for name, schemaYAML := range tests { + name := name + schemaYAML := schemaYAML + t.Run(name, func(t *testing.T) { + t.Parallel() + + mg := NewMockGenerator(JSON) + mg.SetSeed(1) + mock, err := mg.GenerateMock(&highbase.Schema{ + Type: getSchema([]byte(schemaYAML)).Type, + Format: getSchema([]byte(schemaYAML)).Format, + Pattern: getSchema([]byte(schemaYAML)).Pattern, + Enum: getSchema([]byte(schemaYAML)).Enum, + Items: getSchema([]byte(schemaYAML)).Items, + }, "") + + require.NoError(t, err) + assert.NotEmpty(t, mock) + }) + } +} + +func TestMockRenderContext_GuardBranchesAndBudgets(t *testing.T) { + t.Parallel() + + structure := make(map[string]any) + var nilContext *mockRenderContext + assert.False(t, nilContext.diveIntoSchema(&highbase.Schema{}, "root", structure, 0)) + + ctx := newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.err = errors.New("already stopped") + assert.False(t, ctx.diveIntoSchema(&highbase.Schema{}, "root", structure, 0)) + assert.False(t, ctx.checkBudget("nodes", 1, 2)) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + assert.False(t, ctx.diveIntoSchema(nil, "root", structure, 0)) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.nodes = 1 + ctx.options.MaxMockNodes = 1 + assert.False(t, ctx.diveIntoSchema(&highbase.Schema{}, "root", structure, 0)) + assert.ErrorIs(t, ctx.err, ErrMockGenerationBudgetExceeded) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockDepth = 0 + assert.False(t, ctx.diveIntoSchema(&highbase.Schema{}, "root", structure, 1)) + assert.ErrorIs(t, ctx.err, ErrMockGenerationBudgetExceeded) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockDepth = DefaultMaxMockDepth + assert.False(t, ctx.diveIntoSchema(&highbase.Schema{}, "root", structure, DefaultMaxMockDepth+1)) + assert.ErrorIs(t, ctx.err, ErrMockGenerationBudgetExceeded) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.enforceBudgets = false + ctx.options.MaxMockDepth = DefaultMaxMockDepth + require.True(t, ctx.diveIntoSchema(&highbase.Schema{}, "root", structure, DefaultMaxMockDepth+1)) + assert.Equal(t, mockDepthExceededPlaceholder, structure["root"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockBytes = 1 + assert.False(t, ctx.diveIntoSchema(&highbase.Schema{Type: []string{booleanType}}, "root", structure, 0)) + assert.ErrorIs(t, ctx.err, ErrMockGenerationBudgetExceeded) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + _, _, hasKey, entered, ok := ctx.enterSchema(nil, "root", structure) + assert.False(t, hasKey) + assert.True(t, entered) + assert.True(t, ok) + + refSchema := &highbase.Schema{ParentProxy: highbase.CreateSchemaProxyRef("#/components/schemas/Thing")} + ctx.refs = 1 + ctx.options.MaxMockRefExpansions = 1 + _, _, hasKey, entered, ok = ctx.enterSchema(refSchema, "root", structure) + assert.True(t, hasKey) + assert.False(t, entered) + assert.False(t, ok) + + key := mockSchemaKey{ref: "#/components/schemas/Thing"} + ctx.active[key] = 2 + ctx.leaveSchema(mockSchemaKey{}, mockSchemaCacheKey{}, false, nil) + ctx.leaveSchema(key, mockSchemaCacheKey{schema: key, role: mockCacheRole("root")}, true, "cached") + assert.Equal(t, 1, ctx.active[key]) + + _, ok = ctx.schemaKey(nil) + assert.False(t, ok) + assert.Equal(t, 4, estimatedMockValueBytes(nil)) + assert.NotZero(t, estimatedMockValueBytes(struct{ Name string }{Name: "thing"})) +} + +func TestMockRenderContext_RenderStringExamplesAndFallbacks(t *testing.T) { + t.Parallel() + + ctx := newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure := make(map[string]any) + schema := &highbase.Schema{ + Type: []string{stringType}, + Examples: []*yaml.Node{ + utils.CreateYamlNode("first"), + nil, + }, + } + require.True(t, ctx.renderString(schema, itemsType, structure)) + assert.Equal(t, []any{"first", nil}, structure[itemsType]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure = make(map[string]any) + schema = &highbase.Schema{ + Type: []string{stringType}, + Examples: []*yaml.Node{utils.CreateYamlNode("single")}, + } + require.True(t, ctx.renderString(schema, "name", structure)) + assert.Equal(t, "single", structure["name"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure = make(map[string]any) + schema = &highbase.Schema{ + Type: []string{stringType}, + Examples: []*yaml.Node{nil}, + } + require.True(t, ctx.renderString(schema, "name", structure)) + assert.Nil(t, structure["name"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure = make(map[string]any) + schema = &highbase.Schema{ + Type: []string{stringType}, + Pattern: "[", + MinLength: int64Ptr(5), + MaxLength: int64Ptr(5), + } + require.True(t, ctx.renderString(schema, "name", structure)) + assert.Len(t, structure["name"], 5) +} + +func TestMockRenderContext_RenderNumberExamplesAndFormats(t *testing.T) { + t.Parallel() + + ctx := newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure := make(map[string]any) + schema := &highbase.Schema{ + Type: []string{numberType}, + Examples: []*yaml.Node{utils.CreateYamlNode(42)}, + } + require.True(t, ctx.renderNumber(schema, "count", structure)) + assert.Equal(t, 42, structure["count"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure = make(map[string]any) + schema = &highbase.Schema{ + Type: []string{numberType}, + Examples: []*yaml.Node{nil}, + } + require.True(t, ctx.renderNumber(schema, "count", structure)) + assert.Nil(t, structure["count"]) + + for _, format := range []string{bigIntType, decimalType} { + format := format + t.Run(format, func(t *testing.T) { + t.Parallel() + + ctx := newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure := make(map[string]any) + schema := &highbase.Schema{ + Type: []string{numberType}, + Format: format, + Minimum: float64Ptr(1), + Maximum: float64Ptr(2), + } + require.True(t, ctx.renderNumber(schema, "count", structure)) + assert.NotNil(t, structure["count"]) + }) + } +} + +func TestMockGenerationBudgetErrorNil(t *testing.T) { + t.Parallel() + + var err *MockGenerationBudgetError + assert.Equal(t, ErrMockGenerationBudgetExceeded.Error(), err.Error()) +} + +func TestMockRenderContext_RenderObjectBranches(t *testing.T) { + t.Parallel() + + t.Run("property nil and unresolved refs", func(t *testing.T) { + t.Parallel() + + props := orderedmap.New[string, *highbase.SchemaProxy]() + props.Set("empty", nil) + props.Set("missing", highbase.CreateSchemaProxyRef("#/components/schemas/Missing")) + props.Set("nilSchema", highbase.NewSchemaProxy(nil)) + + ctx := newMockRenderContext(emptyDictionarySchemaRenderer(1)) + var callbackName string + ctx.renderer.SetUnresolvedRefHandler(func(name string, _ *highbase.SchemaProxy, _ error) { + callbackName = name + }) + structure := make(map[string]any) + require.True(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + Properties: props, + }, "root", structure, 0)) + + root := structure["root"].(map[string]any) + assert.Empty(t, root["empty"]) + assert.Nil(t, root["missing"]) + assert.Empty(t, root["nilSchema"]) + assert.Equal(t, "missing", callbackName) + }) + + t.Run("required property failure aborts object", func(t *testing.T) { + t.Parallel() + + props := orderedmap.New[string, *highbase.SchemaProxy]() + props.Set("required", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{booleanType}})) + ctx := newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockBytes = 13 + + assert.False(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + Required: []string{"required"}, + Properties: props, + }, "root", make(map[string]any), 0)) + }) + + t.Run("allOf branches", func(t *testing.T) { + t.Parallel() + + ctx := newMockRenderContext(emptyDictionarySchemaRenderer(1)) + var callbackName string + ctx.renderer.SetUnresolvedRefHandler(func(name string, _ *highbase.SchemaProxy, _ error) { + callbackName = name + }) + require.True(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + AllOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxyRef("#/missing")}, + }, "root", make(map[string]any), 0)) + assert.Equal(t, allOfType, callbackName) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockBytes = 1 + assert.False(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + AllOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{booleanType}})}, + }, "root", make(map[string]any), 0)) + + props := orderedmap.New[string, *highbase.SchemaProxy]() + props.Set("name", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{stringType}})) + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure := make(map[string]any) + require.True(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + AllOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{objectType}, Properties: props})}, + }, "root", structure, 0)) + assert.NotEmpty(t, structure["root"].(map[string]any)["name"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure = make(map[string]any) + require.True(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + AllOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{stringType}, Enum: []*yaml.Node{utils.CreateYamlNode("scalar")}})}, + }, "root", structure, 0)) + assert.Equal(t, "scalar", structure["root"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockProperties = 1 + assert.False(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + AllOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{objectType}, Properties: props})}, + }, "root", make(map[string]any), 0)) + }) + + t.Run("dependent schemas", func(t *testing.T) { + t.Parallel() + + dependentSchemas := orderedmap.New[string, *highbase.SchemaProxy]() + dependentSchemas.Set("missingProp", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{objectType}})) + + ctx := newMockRenderContext(emptyDictionarySchemaRenderer(1)) + require.True(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + DependentSchemas: dependentSchemas, + }, "root", make(map[string]any), 0)) + + props := orderedmap.New[string, *highbase.SchemaProxy]() + props.Set("foo", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{objectType}})) + dependentSchemas = orderedmap.New[string, *highbase.SchemaProxy]() + dependentSchemas.Set("foo", highbase.CreateSchemaProxyRef("#/missing")) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + var callbackName string + ctx.renderer.SetUnresolvedRefHandler(func(name string, _ *highbase.SchemaProxy, _ error) { + callbackName = name + }) + require.True(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + Properties: props, + DependentSchemas: dependentSchemas, + }, "root", make(map[string]any), 0)) + assert.Equal(t, "foo", callbackName) + + dependentProps := orderedmap.New[string, *highbase.SchemaProxy]() + dependentProps.Set("bar", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{stringType}})) + dependentSchemas = orderedmap.New[string, *highbase.SchemaProxy]() + dependentSchemas.Set("foo", highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{objectType}, + Properties: dependentProps, + })) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure := make(map[string]any) + require.True(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + Properties: props, + DependentSchemas: dependentSchemas, + }, "root", structure, 0)) + assert.NotEmpty(t, structure["root"].(map[string]any)["foo"].(map[string]any)["bar"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockNodes = 1 + dependentSchemas = orderedmap.New[string, *highbase.SchemaProxy]() + dependentSchemas.Set("foo", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{objectType}})) + assert.False(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + Properties: props, + DependentSchemas: dependentSchemas, + }, "root", make(map[string]any), 0)) + }) + + t.Run("oneOf and anyOf branches", func(t *testing.T) { + t.Parallel() + + ctx := newMockRenderContext(emptyDictionarySchemaRenderer(1)) + var callbackName string + ctx.renderer.SetUnresolvedRefHandler(func(name string, _ *highbase.SchemaProxy, _ error) { + callbackName = name + }) + assert.False(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + OneOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxyRef("#/missing")}, + }, "root", make(map[string]any), 0)) + assert.Equal(t, oneOfType, callbackName) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockBytes = 4 + assert.False(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + OneOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{booleanType}})}, + }, "root", make(map[string]any), 0)) + + props := orderedmap.New[string, *highbase.SchemaProxy]() + props.Set("choice", highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{stringType}})) + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure := make(map[string]any) + require.True(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + OneOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{objectType}, Properties: props})}, + }, "root", structure, 0)) + assert.NotEmpty(t, structure["root"].(map[string]any)["choice"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure = make(map[string]any) + require.True(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + OneOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{stringType}, Enum: []*yaml.Node{utils.CreateYamlNode("scalar")}})}, + }, "root", structure, 0)) + assert.Equal(t, "scalar", structure["root"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockProperties = 1 + assert.False(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + OneOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{objectType}, Properties: props})}, + }, "root", make(map[string]any), 0)) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + callbackName = "" + ctx.renderer.SetUnresolvedRefHandler(func(name string, _ *highbase.SchemaProxy, _ error) { + callbackName = name + }) + assert.False(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + AnyOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxyRef("#/missing")}, + }, "root", make(map[string]any), 0)) + assert.Equal(t, anyOfType, callbackName) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockBytes = 4 + assert.False(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + AnyOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{booleanType}})}, + }, "root", make(map[string]any), 0)) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure = make(map[string]any) + require.True(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + AnyOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{objectType}, Properties: props})}, + }, "root", structure, 0)) + assert.NotEmpty(t, structure["root"].(map[string]any)["choice"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure = make(map[string]any) + require.True(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + AnyOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{stringType}, Enum: []*yaml.Node{utils.CreateYamlNode("scalar")}})}, + }, "root", structure, 0)) + assert.Equal(t, "scalar", structure["root"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockProperties = 1 + assert.False(t, ctx.renderObject(&highbase.Schema{ + Type: []string{objectType}, + AnyOf: []*highbase.SchemaProxy{highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{objectType}, Properties: props})}, + }, "root", make(map[string]any), 0)) + }) +} + +func TestSchemaRenderer_RenderSchemaNilSchema(t *testing.T) { + t.Parallel() + + rendered, err := emptyDictionarySchemaRenderer(1).renderSchema(nil) + require.NoError(t, err) + assert.Nil(t, rendered) +} + +func TestMockRenderContext_RenderArrayBranches(t *testing.T) { + t.Parallel() + + ctx := newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure := make(map[string]any) + require.True(t, ctx.renderArray(&highbase.Schema{Type: []string{arrayType}}, "root", structure, 0)) + assert.NotContains(t, structure, "root") + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + structure = make(map[string]any) + schema := &highbase.Schema{ + Type: []string{arrayType}, + MinItems: int64Ptr(2), + Items: &highbase.DynamicValue[*highbase.SchemaProxy, bool]{ + A: highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{stringType}, + Examples: []*yaml.Node{utils.CreateYamlNode("a"), utils.CreateYamlNode("b")}, + }), + }, + } + require.True(t, ctx.renderArray(schema, "root", structure, 0)) + assert.Equal(t, []any{"a", "b"}, structure["root"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.renderer.SetUnresolvedRefHandler(func(string, *highbase.SchemaProxy, error) {}) + structure = make(map[string]any) + schema = &highbase.Schema{ + Type: []string{arrayType}, + Items: &highbase.DynamicValue[*highbase.SchemaProxy, bool]{ + A: highbase.CreateSchemaProxyRef("#/missing"), + }, + } + require.True(t, ctx.renderArray(schema, "root", structure, 0)) + assert.Equal(t, []any{nil}, structure["root"]) + + ctx = newMockRenderContext(emptyDictionarySchemaRenderer(1)) + ctx.options.MaxMockBytes = 1 + structure = make(map[string]any) + schema = &highbase.Schema{ + Type: []string{arrayType}, + Items: &highbase.DynamicValue[*highbase.SchemaProxy, bool]{ + A: highbase.CreateSchemaProxy(&highbase.Schema{Type: []string{booleanType}}), + }, + } + assert.False(t, ctx.renderArray(schema, "root", structure, 0)) + assert.Equal(t, []any{}, structure["root"]) +} + +func emptyDictionarySchemaRenderer(seed int64) *SchemaRenderer { + return &SchemaRenderer{ + rand: rand.New(rand.NewSource(seed)), + } +} + +func renderStringSchema(t *testing.T, renderer *SchemaRenderer, schemaYAML string) string { + t.Helper() + + compiled := getSchema([]byte(schemaYAML)) + journeyMap := make(map[string]any) + visited := createVisitedMap() + + require.True(t, renderer.DiveIntoSchema(compiled, "pb33f", journeyMap, visited, 0)) + value, ok := journeyMap["pb33f"].(string) + require.True(t, ok) + return value +} + +func componentSchemaForMockTest(t *testing.T, name string, spec string) any { + t.Helper() + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + model, err := doc.BuildV3Model() + require.NoError(t, err) + require.NotNil(t, model.Model.Components) + schemaProxy := model.Model.Components.Schemas.GetOrZero(name) + require.NotNil(t, schemaProxy) + schema := schemaProxy.Schema() + require.NotNil(t, schema) + return schema +} + +func schemaWithStringProperties(count int) *highbase.Schema { + properties := orderedmap.New[string, *highbase.SchemaProxy]() + for i := 0; i < count; i++ { + properties.Set("prop"+strconv.Itoa(i), highbase.CreateSchemaProxy(&highbase.Schema{ + Type: []string{stringType}, + })) + } + return &highbase.Schema{ + Type: []string{objectType}, + Properties: properties, + } +} + +func nestedObjectSchema(depth int) *highbase.Schema { + if depth <= 0 { + return &highbase.Schema{ + Type: []string{stringType}, + Enum: []*yaml.Node{utils.CreateYamlNode("leaf")}, + } + } + properties := orderedmap.New[string, *highbase.SchemaProxy]() + properties.Set("child", highbase.CreateSchemaProxy(nestedObjectSchema(depth-1))) + return &highbase.Schema{ + Type: []string{objectType}, + Required: []string{"child"}, + Properties: properties, + } +} + +func int64Ptr(v int64) *int64 { + return &v +} + +func float64Ptr(v float64) *float64 { + return &v +} diff --git a/renderer/mock_generator.go b/renderer/mock_generator.go index 06860cb1..ecb7c75b 100644 --- a/renderer/mock_generator.go +++ b/renderer/mock_generator.go @@ -6,34 +6,46 @@ package renderer import ( "encoding/json" "fmt" + "reflect" + "strconv" + highbase "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/orderedmap" "go.yaml.in/yaml/v4" - "reflect" - "strconv" ) const ( - Example = "Example" + // Example is the field name used for a single mock example value. + Example = "Example" + + // Examples is the field name used for named mock examples. Examples = "Examples" - Schema = "Schema" -) -type MockType int + // Schema is the field name used for schema-based mock generation. + Schema = "Schema" +) const ( + // JSON renders mocks as JSON. JSON MockType = iota + + // YAML renders mocks as YAML. YAML + + // XML renders mocks as XML. XML ) -// MockGenerator is used to generate mocks for high-level mockable structs or *base.Schema pointers. -// The mock generator will attempt to generate a mock from a struct using the following fields: +// MockType identifies the output format generated by MockGenerator. +type MockType int + +// MockGenerator generates mocks for high-level mockable structs or *base.Schema pointers. +// +// Mockable structs can provide the following fields: // - Example: any type, this is the default example to use if no examples are present. // - Examples: *orderedmap.Map[string, *base.Example], this is a map of examples keyed by name. // - Schema: *base.SchemaProxy, this is the schema to use if no examples are present. // -// The mock generator will attempt to generate a mock from a *base.Schema pointer. // Use NewMockGenerator or NewMockGeneratorWithDictionary to create a new mock generator. type MockGenerator struct { renderer *SchemaRenderer @@ -41,30 +53,33 @@ type MockGenerator struct { pretty bool } -// NewMockGeneratorWithDictionary creates a new mock generator using a custom dictionary. This is useful if you want to -// use a custom dictionary to generate mocks. The location of a text file with one word per line is expected. +// NewMockGeneratorWithDictionary creates a MockGenerator using a custom dictionary file. +// +// The location of a text file with one word per line is expected. func NewMockGeneratorWithDictionary(dictionaryLocation string, mockType MockType) *MockGenerator { renderer := CreateRendererUsingDictionary(dictionaryLocation) return &MockGenerator{renderer: renderer, mockType: mockType} } -// NewMockGenerator creates a new mock generator using the default dictionary. The default is located at /usr/share/dict/words -// on most systems. Windows users will need to use NewMockGeneratorWithDictionary to specify a custom dictionary. +// NewMockGenerator creates a MockGenerator using the default dictionary. +// +// The default is located at /usr/share/dict/words on most systems. Windows users need to use +// NewMockGeneratorWithDictionary to specify a custom dictionary. func NewMockGenerator(mockType MockType) *MockGenerator { renderer := CreateRendererUsingDefaultDictionary() return &MockGenerator{renderer: renderer, mockType: mockType} } -// SetPretty sets the pretty flag on the mock generator. If true, the mock will be rendered with indentation and newlines. -// If false, the mock will be rendered as a single line which is good for API responses. False is the default. -// This option only effects JSON mocks, there is no concept of pretty printing YAML. +// SetPretty configures JSON mocks to render with indentation and newlines. +// +// JSON mocks render as a single line by default. This option affects only JSON; YAML is always rendered in YAML form. func (mg *MockGenerator) SetPretty() { mg.pretty = true } -// DisableRequiredCheck disables renderer required property check when rendering -// a schema for mocks. This means that all properties will be rendered, not just -// the required ones. +// DisableRequiredCheck disables required-property filtering when rendering schema-based mocks. +// +// When disabled, all properties are rendered, not just required properties. func (mg *MockGenerator) DisableRequiredCheck() { mg.renderer.DisableRequiredCheck() } @@ -74,6 +89,13 @@ func (mg *MockGenerator) SetUnresolvedRefHandler(handler UnresolvedRefHandler) { mg.renderer.SetUnresolvedRefHandler(handler) } +// SetMockGenerationOptions sets work and output budgets for generated mock values. +// +// Zero or negative option values are replaced with the package defaults. +func (mg *MockGenerator) SetMockGenerationOptions(options MockGenerationOptions) { + mg.renderer.SetMockGenerationOptions(options) +} + // SetSeed sets a specific seed for the random number generator used by this mock generator. // This is useful for generating deterministic mocks for testing purposes. func (mg *MockGenerator) SetSeed(seed int64) { @@ -81,7 +103,7 @@ func (mg *MockGenerator) SetSeed(seed int64) { } // extractSchema pulls the *base.Schema from a mockable struct or direct *base.Schema. -// Returns an error for unresolved refs or build failures — preserving existing error behavior. +// Returns an error for unresolved refs or build failures while preserving existing error behavior. func (mg *MockGenerator) extractSchema(mock any, v reflect.Value) (*highbase.Schema, error) { switch reflect.TypeOf(mock) { case reflect.TypeOf(&highbase.Schema{}): @@ -120,12 +142,10 @@ func (mg *MockGenerator) renderForType(value any, schema *highbase.Schema) []byt return mg.renderMock(value) } -// GenerateMock generates a mock for a given high-level mockable struct. The mockable struct must contain the following fields: -// Example: any type, this is the default example to use if no examples are present. -// Examples: *orderedmap.Map[string, *base.Example], this is a map of examples keyed by name. -// Schema: *base.SchemaProxy, this is the schema to use if no examples are present. -// The name parameter is optional, if provided, the mock generator will attempt to find an example with the given name. -// If no name is provided, the first example will be used. +// GenerateMock generates a mock for a high-level mockable struct or *base.Schema pointer. +// +// The name parameter is optional. When provided, GenerateMock attempts to select a matching named example. If name is +// empty, the first available example is used. func (mg *MockGenerator) GenerateMock(mock any, name string) ([]byte, error) { if mock == nil || !reflect.ValueOf(mock).IsValid() || reflect.ValueOf(mock).IsNil() { return nil, nil @@ -143,7 +163,6 @@ func (mg *MockGenerator) GenerateMock(mock any, name string) ([]byte, error) { } } mockReady := false - // check if all fields are present, if so, we can generate a mock if fieldCount == 2 { mockReady = true } @@ -152,11 +171,10 @@ func (mg *MockGenerator) GenerateMock(mock any, name string) ([]byte, error) { "fields (%s, %s)", fieldCount, Example, Examples) } - // Extract schema EARLY so Example/Examples paths can use it for XML rendering + // Extract schema before example selection so XML rendering can use schema metadata. schemaValue, schemaErr := mg.extractSchema(mock, v) var fallbackExample *highbase.Example = nil - // trying to find a named example examples := v.FieldByName(Examples) examplesValue := examples.Interface() if examplesValue != nil && !examples.IsNil() { @@ -165,17 +183,14 @@ func (mg *MockGenerator) GenerateMock(mock any, name string) ([]byte, error) { if example, ok := examplesMap.Get(name); ok { return mg.renderForType(example.Value, schemaValue), nil } else { - //take the first example from the list fallbackExample = examplesMap.Oldest().Value } } } } - // looking for an inline example f := v.FieldByName(Example) if !f.IsNil() { - // Pointer/Interface Shenanigans ex := f.Interface() if y, ok := ex.(*yaml.Node); ok { if y != nil { @@ -185,44 +200,40 @@ func (mg *MockGenerator) GenerateMock(mock any, name string) ([]byte, error) { } } if ex != nil { - // try and serialize the example value (very hacky since ex can be anything) return mg.renderForType(ex, schemaValue), nil } } - // rendering fallback if it's not nil if fallbackExample != nil { return mg.renderForType(fallbackExample.Value, schemaValue), nil } - // Surface schema extraction errors only after example paths have had their chance + // Surface schema extraction errors only after example paths have had their chance. if schemaErr != nil { return nil, schemaErr } if schemaValue != nil { - // now let's check the schema for `Examples` and `Example` fields. if schemaValue.Examples != nil { if name != "" { - // try and convert the example to an integer if i, err := strconv.Atoi(name); err == nil { if i < len(schemaValue.Examples) { return mg.renderForType(schemaValue.Examples[i], schemaValue), nil } } } - // if the name is empty, just return the first example return mg.renderForType(schemaValue.Examples[0], schemaValue), nil } - // check the example field if schemaValue.Example != nil { return mg.renderForType(schemaValue.Example, schemaValue), nil } - // render the schema as our last hope. - renderMap := mg.renderer.RenderSchema(schemaValue) + renderMap, renderErr := mg.renderer.RenderSchemaWithError(schemaValue) + if renderErr != nil { + return nil, renderErr + } if renderMap == nil { return nil, fmt.Errorf("unable to render schema for mock, it's empty") } diff --git a/renderer/mock_generator_xml.go b/renderer/mock_generator_xml.go index c7510780..2500f4f7 100644 --- a/renderer/mock_generator_xml.go +++ b/renderer/mock_generator_xml.go @@ -125,9 +125,9 @@ func appendNamespaceAttr(attrs []xml.Attr, prefix, namespace string) []xml.Attr // RenderXML renders a value as XML. If schema is provided, uses its XML metadata // (xml.name, xml.attribute, xml.namespace, xml.prefix, xml.wrapped) for correct output. -// If schema is nil, falls back to basic element-based XML (map keys → element names). +// If schema is nil, falls back to basic element-based XML using map keys as element names. // -// Note: nodeType "cdata" is treated as "text" in this version — Go's xml.Encoder has +// Note: nodeType "cdata" is treated as "text" in this version because Go's xml.Encoder has // no first-class CDATA token support. func (mg *MockGenerator) RenderXML(value any, schema *highbase.Schema) []byte { if value == nil { @@ -192,7 +192,7 @@ func (mg *MockGenerator) renderXMLValue(enc *xml.Encoder, start xml.StartElement // renderXMLMap renders a map as an XML element with child elements, attributes, and text content. func (mg *MockGenerator) renderXMLMap(enc *xml.Encoder, start xml.StartElement, m map[string]any, schema *highbase.Schema) { // Three-pass rendering: - // 1. Collect attributes → add to start element + // 1. Collect attributes and add them to the start element. // 2. Collect text/cdata nodes // 3. Emit child elements diff --git a/renderer/mock_render_context.go b/renderer/mock_render_context.go new file mode 100644 index 00000000..105cb142 --- /dev/null +++ b/renderer/mock_render_context.go @@ -0,0 +1,479 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package renderer + +import ( + "fmt" + "slices" + + "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +const mockSchemaCacheRoleDefault = "schema" + +type mockSchemaKey struct { + node *yaml.Node + ref string + schema *base.Schema +} + +type mockSchemaCacheKey struct { + schema mockSchemaKey + role string +} + +type mockRenderContext struct { + renderer *SchemaRenderer + options MockGenerationOptions + active map[mockSchemaKey]int + completed map[mockSchemaCacheKey]any + enforceBudgets bool + nodes int + props int + refs int + bytes int + err error +} + +func newMockRenderContext(renderer *SchemaRenderer) *mockRenderContext { + if renderer == nil { + renderer = CreateRendererUsingDefaultDictionary() + } + return &mockRenderContext{ + renderer: renderer, + options: renderer.effectiveMockGenerationOptions(), + active: make(map[mockSchemaKey]int), + completed: make(map[mockSchemaCacheKey]any), + enforceBudgets: true, + } +} + +func (ctx *mockRenderContext) diveIntoSchema(schema *base.Schema, key string, structure map[string]any, depth int) bool { + if ctx == nil || ctx.err != nil { + return false + } + if schema == nil { + return false + } + if schema.Example != nil { + var example any + _ = schema.Example.Decode(&example) + structure[key] = example + return ctx.noteValue(example) + } + if !ctx.noteNode() { + return false + } + + schemaKey, cacheKey, hasSchemaKey, entered, ok := ctx.enterSchema(schema, key, structure) + if !entered { + return ok && ctx.err == nil + } + defer func() { + ctx.leaveSchema(schemaKey, cacheKey, hasSchemaKey, structure[key]) + }() + + if depth > ctx.options.MaxMockDepth { + if ctx.enforceBudgets { + ctx.err = &MockGenerationBudgetError{Budget: "depth", Limit: ctx.options.MaxMockDepth, Actual: depth} + return false + } + structure[key] = mockDepthExceededPlaceholder + return ctx.noteValue(structure[key]) + } + + if slices.Contains(schema.Type, stringType) { + return ctx.renderString(schema, key, structure) + } + + if slices.Contains(schema.Type, numberType) || + slices.Contains(schema.Type, integerType) || + slices.Contains(schema.Type, bigIntType) || + slices.Contains(schema.Type, decimalType) { + return ctx.renderNumber(schema, key, structure) + } + + if slices.Contains(schema.Type, booleanType) { + structure[key] = true + if !ctx.noteValue(true) { + return false + } + } + + if ctx.isObjectSchema(schema) { + return ctx.renderObject(schema, key, structure, depth) + } + + if slices.Contains(schema.Type, arrayType) { + return ctx.renderArray(schema, key, structure, depth) + } + + return true +} + +func (ctx *mockRenderContext) renderString(schema *base.Schema, key string, structure map[string]any) bool { + structure[key] = ctx.renderer.renderMockStringValue(schema, key, ctx.options.MaxGeneratedStringBytes) + return ctx.noteValue(structure[key]) +} + +func (ctx *mockRenderContext) renderNumber(schema *base.Schema, key string, structure map[string]any) bool { + structure[key] = ctx.renderer.renderMockNumberValue(schema) + return ctx.noteValue(structure[key]) +} + +func (ctx *mockRenderContext) renderObject(schema *base.Schema, key string, structure map[string]any, depth int) bool { + propertyMap := make(map[string]any) + var compositionValue any + hasCompositionValue := false + + if schema.Properties != nil { + checkProps := orderedmap.New[string, *base.SchemaProxy]() + if ctx.renderer.disableRequired || len(schema.Required) == 0 { + for name, value := range schema.Properties.FromOldest() { + checkProps.Set(name, value) + } + } + for _, requiredProp := range schema.Required { + checkProps.Set(requiredProp, schema.Properties.GetOrZero(requiredProp)) + } + + for propName, propValue := range checkProps.FromOldest() { + if !ctx.noteProperty(propName) { + return false + } + if propValue == nil { + propertyMap[propName] = make(map[string]any) + continue + } + propertySchema := propValue.Schema() + required := slices.Contains(schema.Required, propName) + if propertySchema != nil { + success := ctx.diveIntoSchema(propertySchema, propName, propertyMap, depth+1) + if !success { + if required { + return false + } + delete(propertyMap, propName) + continue + } + } else if propValue.IsReference() { + propertyMap[propName] = nil + if ctx.renderer.onUnresolvedRef != nil { + ctx.renderer.onUnresolvedRef(propName, propValue, propValue.GetBuildError()) + } + } else { + propertyMap[propName] = make(map[string]any) + } + } + } + + if schema.AllOf != nil { + allOfMap := make(map[string]any) + for _, allOfSchema := range schema.AllOf { + allOfCompiled := allOfSchema.Schema() + if allOfCompiled == nil { + if ctx.renderer.onUnresolvedRef != nil { + ctx.renderer.onUnresolvedRef(allOfType, allOfSchema, allOfSchema.GetBuildError()) + } + continue + } + if !ctx.diveIntoSchema(allOfCompiled, allOfType, allOfMap, depth+1) { + return false + } + if value, ok := allOfMap[allOfType]; ok { + if m, ok := value.(map[string]any); ok { + for k, v := range m { + if !ctx.noteProperty(k) { + return false + } + propertyMap[k] = v + } + } else { + compositionValue = value + hasCompositionValue = true + } + } + } + } + + if schema.DependentSchemas != nil { + dependentSchemasMap := make(map[string]any) + for k, dependentSchema := range schema.DependentSchemas.FromOldest() { + if propertyMap[k] == nil { + continue + } + dependentSchemaCompiled := dependentSchema.Schema() + if dependentSchemaCompiled == nil { + if ctx.renderer.onUnresolvedRef != nil { + ctx.renderer.onUnresolvedRef(k, dependentSchema, dependentSchema.GetBuildError()) + } + continue + } + if !ctx.diveIntoSchema(dependentSchemaCompiled, k, dependentSchemasMap, depth+1) { + return false + } + for i, v := range dependentSchemasMap[k].(map[string]any) { + propertyMap[k].(map[string]any)[i] = v + } + } + } + + oneOfSuccess := true + for _, oneOfSchema := range schema.OneOf { + oneOfSuccess = false + oneOfMap := make(map[string]any) + oneOfCompiled := oneOfSchema.Schema() + if oneOfCompiled == nil { + if ctx.renderer.onUnresolvedRef != nil { + ctx.renderer.onUnresolvedRef(oneOfType, oneOfSchema, oneOfSchema.GetBuildError()) + } + continue + } + if !ctx.diveIntoSchema(oneOfCompiled, oneOfType, oneOfMap, depth+1) { + continue + } + if value, ok := oneOfMap[oneOfType]; ok { + if m, ok := value.(map[string]any); ok { + for k, v := range m { + if !ctx.noteProperty(k) { + return false + } + propertyMap[k] = v + } + } else { + compositionValue = value + hasCompositionValue = true + } + } + oneOfSuccess = true + break + } + if !oneOfSuccess { + return false + } + + anyOfSuccess := true + for _, anyOfSchema := range schema.AnyOf { + anyOfSuccess = false + anyOfMap := make(map[string]any) + anyOfCompiled := anyOfSchema.Schema() + if anyOfCompiled == nil { + if ctx.renderer.onUnresolvedRef != nil { + ctx.renderer.onUnresolvedRef(anyOfType, anyOfSchema, anyOfSchema.GetBuildError()) + } + continue + } + if !ctx.diveIntoSchema(anyOfCompiled, anyOfType, anyOfMap, depth+1) { + continue + } + if value, ok := anyOfMap[anyOfType]; ok { + if m, ok := value.(map[string]any); ok { + for k, v := range m { + if !ctx.noteProperty(k) { + return false + } + propertyMap[k] = v + } + } else { + compositionValue = value + hasCompositionValue = true + } + } + anyOfSuccess = true + break + } + if !anyOfSuccess { + return false + } + + if len(propertyMap) == 0 && hasCompositionValue { + structure[key] = compositionValue + return ctx.noteValue(compositionValue) + } + structure[key] = propertyMap + return ctx.noteValue(propertyMap) +} + +func (ctx *mockRenderContext) renderArray(schema *base.Schema, key string, structure map[string]any, depth int) bool { + itemsSchema := schema.Items + if itemsSchema == nil || !itemsSchema.IsA() { + return true + } + + var minItems int64 = 1 + if schema.MinItems != nil { + minItems = *schema.MinItems + } + + renderedItems := []any{} + for i := int64(0); i < minItems; i++ { + itemMap := make(map[string]any) + itemsSchemaCompiled := itemsSchema.A.Schema() + if itemsSchemaCompiled == nil { + if ctx.renderer.onUnresolvedRef != nil { + ctx.renderer.onUnresolvedRef(itemsType, itemsSchema.A, itemsSchema.A.GetBuildError()) + } + renderedItems = append(renderedItems, nil) + break + } + if !ctx.diveIntoSchema(itemsSchemaCompiled, itemsType, itemMap, depth+1) { + renderedItems = []any{} + break + } + if multipleItems, ok := itemMap[itemsType].([]any); ok { + renderedItems = multipleItems + } else { + renderedItems = append(renderedItems, itemMap[itemsType]) + } + } + structure[key] = renderedItems + return ctx.noteValue(renderedItems) +} + +func (ctx *mockRenderContext) isObjectSchema(schema *base.Schema) bool { + return slices.Contains(schema.Type, objectType) || + (schema.Properties != nil && schema.Properties.Len() > 0) || + schema.AllOf != nil || + (schema.DependentSchemas != nil && schema.DependentSchemas.Len() > 0) || + schema.OneOf != nil || + schema.AnyOf != nil +} + +func (ctx *mockRenderContext) enterSchema(schema *base.Schema, key string, structure map[string]any) (mockSchemaKey, mockSchemaCacheKey, bool, bool, bool) { + schemaKey, ok := ctx.schemaKey(schema) + if !ok { + return mockSchemaKey{}, mockSchemaCacheKey{}, false, true, true + } + cacheKey := mockSchemaCacheKey{schema: schemaKey, role: mockCacheRole(key)} + if cached, found := ctx.completed[cacheKey]; found { + copied := copyMockValue(cached) + structure[key] = copied + _ = ctx.noteValue(copied) + return schemaKey, cacheKey, true, false, true + } + if ctx.active[schemaKey] > 0 { + return schemaKey, cacheKey, true, false, false + } + if schema.ParentProxy != nil && schema.ParentProxy.IsReference() && !ctx.noteRefExpansion() { + return schemaKey, cacheKey, true, false, false + } + ctx.active[schemaKey]++ + return schemaKey, cacheKey, true, true, true +} + +func (ctx *mockRenderContext) leaveSchema(schemaKey mockSchemaKey, cacheKey mockSchemaCacheKey, hasSchemaKey bool, value any) { + if !hasSchemaKey { + return + } + if count := ctx.active[schemaKey]; count <= 1 { + delete(ctx.active, schemaKey) + } else { + ctx.active[schemaKey] = count - 1 + } + if ctx.err == nil && value != nil { + ctx.completed[cacheKey] = copyMockValue(value) + } +} + +func mockCacheRole(key string) string { + switch key { + case itemsType, allOfType, oneOfType, anyOfType: + return key + default: + return mockSchemaCacheRoleDefault + } +} + +func (ctx *mockRenderContext) schemaKey(schema *base.Schema) (mockSchemaKey, bool) { + if schema == nil { + return mockSchemaKey{}, false + } + if low := schema.GoLow(); low != nil && low.RootNode != nil { + return mockSchemaKey{node: low.RootNode}, true + } + if schema.ParentProxy != nil && schema.ParentProxy.IsReference() { + if ref := schema.ParentProxy.GetReference(); ref != "" { + return mockSchemaKey{ref: ref}, true + } + } + return mockSchemaKey{schema: schema}, true +} + +func (ctx *mockRenderContext) noteNode() bool { + ctx.nodes++ + return ctx.checkBudget("nodes", ctx.options.MaxMockNodes, ctx.nodes) +} + +func (ctx *mockRenderContext) noteProperty(name string) bool { + ctx.props++ + ctx.bytes += len(name) + 4 + return ctx.checkBudget("properties", ctx.options.MaxMockProperties, ctx.props) && + ctx.checkBudget("bytes", ctx.options.MaxMockBytes, ctx.bytes) +} + +func (ctx *mockRenderContext) noteRefExpansion() bool { + ctx.refs++ + return ctx.checkBudget("ref expansions", ctx.options.MaxMockRefExpansions, ctx.refs) +} + +func (ctx *mockRenderContext) noteValue(value any) bool { + ctx.bytes += estimatedMockValueBytes(value) + return ctx.checkBudget("bytes", ctx.options.MaxMockBytes, ctx.bytes) +} + +func (ctx *mockRenderContext) checkBudget(name string, limit int, actual int) bool { + if ctx.err != nil { + return false + } + if !ctx.enforceBudgets { + return true + } + if limit <= 0 || actual <= limit { + return true + } + ctx.err = &MockGenerationBudgetError{Budget: name, Limit: limit, Actual: actual} + return false +} + +func copyMockValue(value any) any { + switch v := value.(type) { + case map[string]any: + copied := make(map[string]any, len(v)) + for key, child := range v { + copied[key] = copyMockValue(child) + } + return copied + case []any: + copied := make([]any, len(v)) + for i, child := range v { + copied[i] = copyMockValue(child) + } + return copied + default: + return value + } +} + +func estimatedMockValueBytes(value any) int { + switch v := value.(type) { + case nil: + return 4 + case string: + return len(v) + 2 + case []any: + return 2 + len(v)*2 + case map[string]any: + return 2 + len(v)*4 + case bool: + return 5 + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return 20 + case float32, float64: + return 24 + default: + return len(fmt.Sprint(v)) + } +} diff --git a/renderer/mock_value_renderer.go b/renderer/mock_value_renderer.go new file mode 100644 index 00000000..1c3702a6 --- /dev/null +++ b/renderer/mock_value_renderer.go @@ -0,0 +1,154 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package renderer + +import ( + "encoding/base64" + "fmt" + "time" + + "github.com/pb33f/libopenapi/datamodel/high/base" +) + +func (wr *SchemaRenderer) renderMockStringValue(schema *base.Schema, key string, maxGeneratedStringBytes int) any { + if schema == nil { + return nil + } + if schema.Enum != nil && len(schema.Enum) > 0 { + enum := schema.Enum[wr.rand.Int()%len(schema.Enum)] + var example any + _ = enum.Decode(&example) + return example + } + + var minLength int64 = 3 + var maxLength int64 = 10 + if schema.MinLength != nil { + minLength = *schema.MinLength + } + hasSchemaMaxLength := schema.MaxLength != nil + schemaMaxLength := maxLength + if schema.MaxLength != nil { + maxLength = *schema.MaxLength + schemaMaxLength = *schema.MaxLength + } + minLength, maxLength = boundedGeneratedStringRange(minLength, maxLength, maxGeneratedStringBytes) + limitGeneratedString := func(value string) string { + return truncateStringBytes(value, maxGeneratedStringBytes) + } + randomWord := func() string { + return limitGeneratedString(wr.RandomWord(minLength, maxLength, 0)) + } + + if schema.Examples != nil && len(schema.Examples) > 0 { + if len(schema.Examples) > 1 && key == itemsType { + renderedExamples := make([]any, len(schema.Examples)) + for i, exmp := range schema.Examples { + if exmp != nil { + var ex any + _ = exmp.Decode(&ex) + renderedExamples[i] = fmt.Sprint(ex) + } + } + return renderedExamples + } + var renderedExample any + if exmp := schema.Examples[0]; exmp != nil { + var ex any + _ = exmp.Decode(&ex) + renderedExample = fmt.Sprint(ex) + } + return renderedExample + } + + switch schema.Format { + case dateTimeType: + return limitGeneratedString(time.Now().Format(time.RFC3339)) + case dateType: + return limitGeneratedString(time.Now().Format("2006-01-02")) + case timeType: + return limitGeneratedString(time.Now().Format("15:04:05")) + case emailType: + return limitGeneratedString(fmt.Sprintf("%s@%s.com", randomWord(), randomWord())) + case hostnameType: + return limitGeneratedString(fmt.Sprintf("%s.com", randomWord())) + case ipv4Type: + return limitGeneratedString(fmt.Sprintf("%d.%d.%d.%d", + wr.rand.Int()%255, wr.rand.Int()%255, wr.rand.Int()%255, wr.rand.Int()%255)) + case ipv6Type: + return limitGeneratedString(fmt.Sprintf("%04x:%04x:%04x:%04x:%04x:%04x:%04x:%04x", + wr.rand.Intn(65535), wr.rand.Intn(65535), wr.rand.Intn(65535), wr.rand.Intn(65535), + wr.rand.Intn(65535), wr.rand.Intn(65535), wr.rand.Intn(65535), wr.rand.Intn(65535), + )) + case uriType: + return limitGeneratedString(fmt.Sprintf("https://%s-%s-%s.com/%s", + randomWord(), randomWord(), randomWord(), randomWord())) + case uriReferenceType: + return limitGeneratedString(fmt.Sprintf("/%s/%s", randomWord(), randomWord())) + case uuidType: + return limitGeneratedString(wr.PseudoUUID()) + case byteType, passwordType: + return randomWord() + case binaryType: + return limitGeneratedString(base64.StdEncoding.EncodeToString([]byte(randomWord()))) + case bigIntType: + return limitGeneratedString(fmt.Sprint(wr.RandomInt(minLength, maxLength))) + case decimalType: + return limitGeneratedString(fmt.Sprint(wr.RandomFloat64())) + default: + if schema.Pattern != "" { + str, err := wr.generatePatternString(schema.Pattern, schemaMaxLength, hasSchemaMaxLength) + if err == nil { + return str + } + } + return randomWord() + } +} + +func (wr *SchemaRenderer) renderMockNumberValue(schema *base.Schema) any { + if schema == nil { + return nil + } + if schema.Enum != nil && len(schema.Enum) > 0 { + enum := schema.Enum[wr.rand.Int()%len(schema.Enum)] + var example any + _ = enum.Decode(&example) + return example + } + + var minimum int64 = 1 + var maximum int64 = 100 + if schema.Minimum != nil { + minimum = int64(*schema.Minimum) + } + if schema.Maximum != nil { + maximum = int64(*schema.Maximum) + } + + if schema.Examples != nil && len(schema.Examples) > 0 { + var renderedExample any + if exmp := schema.Examples[0]; exmp != nil { + var ex any + _ = exmp.Decode(&ex) + renderedExample = ex + } + return renderedExample + } + + switch schema.Format { + case floatType: + return wr.rand.Float32() + case doubleType: + return wr.rand.Float64() + case int32Type: + return int(wr.RandomInt(minimum, maximum)) + case bigIntType: + return wr.RandomInt(minimum, maximum) + case decimalType: + return wr.RandomFloat64() + default: + return wr.RandomInt(minimum, maximum) + } +} diff --git a/renderer/mock_value_renderer_test.go b/renderer/mock_value_renderer_test.go new file mode 100644 index 00000000..2ecb541b --- /dev/null +++ b/renderer/mock_value_renderer_test.go @@ -0,0 +1,36 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package renderer + +import ( + "testing" + + highbase "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/stretchr/testify/assert" +) + +func TestMockValueRendererHandlesNilSchemas(t *testing.T) { + t.Parallel() + + wr := CreateRendererUsingDefaultDictionary() + + assert.Nil(t, wr.renderMockStringValue(nil, rootType, DefaultMaxGeneratedStringBytes)) + assert.Nil(t, wr.renderMockNumberValue(nil)) +} + +func TestMockValueRendererCapsIPv4Strings(t *testing.T) { + t.Parallel() + + wr := emptyDictionarySchemaRenderer(1) + wr.SetMockGenerationOptions(MockGenerationOptions{MaxGeneratedStringBytes: 4}) + + value := wr.renderMockStringValue(&highbase.Schema{ + Type: []string{stringType}, + Format: ipv4Type, + }, rootType, wr.effectiveMockGenerationOptions().MaxGeneratedStringBytes) + + rendered, ok := value.(string) + assert.True(t, ok) + assert.LessOrEqual(t, len(rendered), 4) +} diff --git a/renderer/schema_renderer.go b/renderer/schema_renderer.go index 64f3b586..9e3b3238 100644 --- a/renderer/schema_renderer.go +++ b/renderer/schema_renderer.go @@ -5,7 +5,7 @@ package renderer import ( cryptoRand "crypto/rand" - "encoding/base64" + "errors" "fmt" "io" "math/rand" @@ -13,6 +13,7 @@ import ( "slices" "strings" "time" + "unicode/utf8" "github.com/lucasjones/reggen" "github.com/pb33f/libopenapi/datamodel/high/base" @@ -49,21 +50,93 @@ const ( anyOfType = "anyOf" oneOfType = "oneOf" itemsType = "items" + + mockDepthExceededPlaceholder = "too deep to continue rendering..." + + // DefaultMaxPatternRepeatBudget is the default regex repeat budget used when generating string mocks from patterns. + DefaultMaxPatternRepeatBudget = 32 + + // DefaultMaxGeneratedStringBytes is the default byte ceiling for each generated string mock value. + DefaultMaxGeneratedStringBytes = 4096 + + // DefaultMaxMockDepth is the default maximum recursive schema depth for generated mocks. + DefaultMaxMockDepth = 100 + + // DefaultMaxMockNodes is the default maximum number of schema nodes visited for a generated mock. + DefaultMaxMockNodes = 10000 + + // DefaultMaxMockProperties is the default maximum number of object properties rendered for a generated mock. + DefaultMaxMockProperties = 5000 + + // DefaultMaxMockRefExpansions is the default maximum number of reference expansions for a generated mock. + DefaultMaxMockRefExpansions = 2000 + + // DefaultMaxMockBytes is the default approximate generated mock byte budget before serialization. + DefaultMaxMockBytes = 1024 * 1024 + + // letterBytes is used to generate random words when no dictionary is configured. + letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" ) -// used to generate random words if there is no dictionary applied. -const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +// ErrMockGenerationBudgetExceeded is wrapped by errors caused by configured mock generation budgets. +var ErrMockGenerationBudgetExceeded = errors.New("mock generation budget exceeded") // UnresolvedRefHandler is called when a $ref property cannot be resolved during rendering. type UnresolvedRefHandler func(propertyName string, proxy *base.SchemaProxy, err error) -// SchemaRenderer is a renderer that will generate random words, numbers and values based on a dictionary file. -// The dictionary is just a slice of strings that is used to generate random words. +// SchemaRenderer generates mock values from schemas, examples and schema constraints. +// +// When a dictionary is configured, it is used as the source for generated words. type SchemaRenderer struct { words []string disableRequired bool rand *rand.Rand onUnresolvedRef UnresolvedRefHandler + mockOptions MockGenerationOptions +} + +// MockGenerationBudgetError describes which mock generation budget was exceeded. +type MockGenerationBudgetError struct { + // Budget is the name of the budget that was exceeded. + Budget string + // Limit is the configured budget value. + Limit int + // Actual is the observed value that exceeded the limit. + Actual int +} + +func (e *MockGenerationBudgetError) Error() string { + if e == nil { + return ErrMockGenerationBudgetExceeded.Error() + } + return fmt.Sprintf("%s: %s budget exceeded: %d > %d", + ErrMockGenerationBudgetExceeded, e.Budget, e.Actual, e.Limit) +} + +// Unwrap returns ErrMockGenerationBudgetExceeded for errors.Is checks. +func (e *MockGenerationBudgetError) Unwrap() error { + return ErrMockGenerationBudgetExceeded +} + +// MockGenerationOptions controls how much work the renderer may spend generating mock values. +// +// Zero or negative values use the package defaults. OpenAPI schema constraints such as maxLength are still used as +// validity hints, but they are not treated as permission to perform unbounded generation work. +type MockGenerationOptions struct { + // MaxPatternRepeatBudget limits the repeat budget passed to regex-based string generation. + MaxPatternRepeatBudget int + // MaxGeneratedStringBytes limits the final size of generated string values. + MaxGeneratedStringBytes int + // MaxMockDepth limits recursive schema depth while building mock structures. + MaxMockDepth int + // MaxMockNodes limits the number of schema nodes visited while building a mock. + MaxMockNodes int + // MaxMockProperties limits the number of object properties rendered while building a mock. + MaxMockProperties int + // MaxMockRefExpansions limits the number of $ref schema expansions while building a mock. + MaxMockRefExpansions int + // MaxMockBytes limits approximate mock structure size before serialization. + MaxMockBytes int } // SetUnresolvedRefHandler sets a callback that is invoked when a $ref cannot be resolved during rendering. @@ -71,38 +144,77 @@ func (wr *SchemaRenderer) SetUnresolvedRefHandler(handler UnresolvedRefHandler) wr.onUnresolvedRef = handler } -// CreateRendererUsingDictionary will create a new SchemaRenderer using a custom dictionary file. +// SetMockGenerationOptions sets work and output budgets for generated mock values. +// +// Zero or negative option values are replaced with the package defaults. +func (wr *SchemaRenderer) SetMockGenerationOptions(options MockGenerationOptions) { + wr.mockOptions = normalizeMockGenerationOptions(options) +} + +// CreateRendererUsingDictionary creates a SchemaRenderer using a custom dictionary file. +// // The location of a text file with one word per line is expected. func CreateRendererUsingDictionary(dictionaryLocation string) *SchemaRenderer { - // try and read in the dictionary file words := ReadDictionary(dictionaryLocation) return &SchemaRenderer{ - words: words, - rand: rand.New(rand.NewSource(time.Now().UnixNano())), + words: words, + rand: rand.New(rand.NewSource(time.Now().UnixNano())), + mockOptions: normalizeMockGenerationOptions(MockGenerationOptions{}), } } -// CreateRendererUsingDefaultDictionary will create a new SchemaRenderer using the default dictionary file. +// CreateRendererUsingDefaultDictionary creates a SchemaRenderer using the default dictionary file. +// // The default dictionary is located at /usr/share/dict/words on most systems. -// Windows users will need to use CreateRendererUsingDictionary to specify a custom dictionary. +// Windows users need to use CreateRendererUsingDictionary to specify a custom dictionary. func CreateRendererUsingDefaultDictionary() *SchemaRenderer { wr := new(SchemaRenderer) wr.words = ReadDictionary("/usr/share/dict/words") wr.rand = rand.New(rand.NewSource(time.Now().UnixNano())) + wr.mockOptions = normalizeMockGenerationOptions(MockGenerationOptions{}) return wr } -// RenderSchema takes a schema and renders it into an interface, ready to be converted to JSON or YAML. +// RenderSchema renders a schema into a value that can be serialized as JSON or YAML. +// +// RenderSchema preserves its historical best-effort behavior. Use RenderSchemaWithError to enforce and inspect mock +// generation work budget failures. func (wr *SchemaRenderer) RenderSchema(schema *base.Schema) any { - // dive into the schema and render it + return wr.renderSchemaBestEffort(schema) +} + +// RenderSchemaWithError renders a schema into a value that can be serialized as JSON or YAML. +// +// If mock generation exceeds a configured work budget, the returned error wraps ErrMockGenerationBudgetExceeded. +func (wr *SchemaRenderer) RenderSchemaWithError(schema *base.Schema) (any, error) { + return wr.renderSchema(schema) +} + +func (wr *SchemaRenderer) renderSchema(schema *base.Schema) (any, error) { + return wr.renderSchemaWithBudgets(schema, true) +} + +func (wr *SchemaRenderer) renderSchemaBestEffort(schema *base.Schema) any { + rendered, _ := wr.renderSchemaWithBudgets(schema, false) + return rendered +} + +func (wr *SchemaRenderer) renderSchemaWithBudgets(schema *base.Schema, enforceBudgets bool) (any, error) { structure := make(map[string]any) - visited := make(map[string]bool) - wr.DiveIntoSchema(schema, rootType, structure, visited, 0) - return structure[rootType] + ctx := newMockRenderContext(wr) + ctx.enforceBudgets = enforceBudgets + if !ctx.diveIntoSchema(schema, rootType, structure, 0) { + if ctx.err != nil { + return nil, ctx.err + } + return nil, nil + } + return structure[rootType], nil } -// DisableRequiredCheck will disable the required check when rendering a schema. This means that all properties -// will be rendered, not just the required ones. +// DisableRequiredCheck disables required-property filtering when rendering a schema. +// +// When disabled, all properties are rendered, not just required properties. // https://github.com/pb33f/libopenapi/issues/200 func (wr *SchemaRenderer) DisableRequiredCheck() { wr.disableRequired = true @@ -114,13 +226,14 @@ func (wr *SchemaRenderer) SetSeed(seed int64) { wr.rand = rand.New(rand.NewSource(seed)) } -// DiveIntoSchema will dive into a schema and inject values from examples into a map. If there are no examples in -// the schema, then the renderer will attempt to generate a value based on the schema type, format and pattern. +// DiveIntoSchema renders a schema into structure at key. +// +// Examples are preferred. If no examples are available, the renderer generates a value from the schema type, format +// and pattern. func (wr *SchemaRenderer) DiveIntoSchema(schema *base.Schema, key string, structure map[string]any, visited map[string]bool, depth int) bool { if schema == nil { return false } - // got an example? use it, we're done here. if schema.Example != nil { var example any _ = schema.Example.Decode(&example) @@ -129,120 +242,16 @@ func (wr *SchemaRenderer) DiveIntoSchema(schema *base.Schema, key string, struct return true } - // emergency break to prevent stack overflow from ever occurring + // Prevent unbounded recursion on deeply nested schemas. if depth > 100 { - structure[key] = "to deep to continue rendering..." + structure[key] = mockDepthExceededPlaceholder return true } // render out a string. if slices.Contains(schema.Type, stringType) { - // check for an enum, if there is one, then pick a random value from it. - if schema.Enum != nil && len(schema.Enum) > 0 { - enum := schema.Enum[wr.rand.Int()%len(schema.Enum)] - - var example any - _ = enum.Decode(&example) - - structure[key] = example - } else { - - // generate a random value based on the schema format, pattern and length values. - var minLength int64 = 3 - var maxLength int64 = 10 - - if schema.MinLength != nil { - minLength = *schema.MinLength - } - if schema.MaxLength != nil { - maxLength = *schema.MaxLength - } - - // if there are examples, use them. - if schema.Examples != nil && len(schema.Examples) > 0 { - var renderedExample any - - // multi examples and the type is an array? then render all examples. - if len(schema.Examples) > 1 && key == itemsType { - renderedExamples := make([]any, len(schema.Examples)) - for i, exmp := range schema.Examples { - if exmp != nil { - var ex any - _ = exmp.Decode(&ex) - renderedExamples[i] = fmt.Sprint(ex) - } - } - structure[key] = renderedExamples - return true - } else { - // render the first example - exmp := schema.Examples[0] - if exmp != nil { - var ex any - _ = exmp.Decode(&ex) - renderedExample = fmt.Sprint(ex) - } - structure[key] = renderedExample - return true - } - } - - switch schema.Format { - case dateTimeType: - structure[key] = time.Now().Format(time.RFC3339) - case dateType: - structure[key] = time.Now().Format("2006-01-02") - case timeType: - structure[key] = time.Now().Format("15:04:05") - case emailType: - structure[key] = fmt.Sprintf("%s@%s.com", - wr.RandomWord(minLength, maxLength, 0), - wr.RandomWord(minLength, maxLength, 0)) - case hostnameType: - structure[key] = fmt.Sprintf("%s.com", wr.RandomWord(minLength, maxLength, 0)) - case ipv4Type: - structure[key] = fmt.Sprintf("%d.%d.%d.%d", - wr.rand.Int()%255, wr.rand.Int()%255, wr.rand.Int()%255, wr.rand.Int()%255) - case ipv6Type: - structure[key] = fmt.Sprintf("%04x:%04x:%04x:%04x:%04x:%04x:%04x:%04x", - wr.rand.Intn(65535), wr.rand.Intn(65535), wr.rand.Intn(65535), wr.rand.Intn(65535), - wr.rand.Intn(65535), wr.rand.Intn(65535), wr.rand.Intn(65535), wr.rand.Intn(65535), - ) - case uriType: - structure[key] = fmt.Sprintf("https://%s-%s-%s.com/%s", - wr.RandomWord(minLength, maxLength, 0), - wr.RandomWord(minLength, maxLength, 0), - wr.RandomWord(minLength, maxLength, 0), - wr.RandomWord(minLength, maxLength, 0)) - case uriReferenceType: - structure[key] = fmt.Sprintf("/%s/%s", - wr.RandomWord(minLength, maxLength, 0), - wr.RandomWord(minLength, maxLength, 0)) - case uuidType: - structure[key] = wr.PseudoUUID() - case byteType: - structure[key] = wr.RandomWord(minLength, maxLength, 0) - case passwordType: - structure[key] = wr.RandomWord(minLength, maxLength, 0) - case binaryType: - structure[key] = base64.StdEncoding.EncodeToString([]byte(wr.RandomWord(minLength, maxLength, 0))) - case bigIntType: - structure[key] = fmt.Sprint(wr.RandomInt(minLength, maxLength)) - case decimalType: - structure[key] = fmt.Sprint(wr.RandomFloat64()) - default: - // if there is a pattern supplied, then try and generate a string from it. - if schema.Pattern != "" { - str, err := reggen.Generate(schema.Pattern, int(maxLength)) - if err == nil { - structure[key] = str - } - } else { - // last resort, generate a random value - structure[key] = wr.RandomWord(minLength, maxLength, 0) - } - } - } + options := wr.effectiveMockGenerationOptions() + structure[key] = wr.renderMockStringValue(schema, key, options.MaxGeneratedStringBytes) return true } @@ -252,54 +261,7 @@ func (wr *SchemaRenderer) DiveIntoSchema(schema *base.Schema, key string, struct slices.Contains(schema.Type, bigIntType) || slices.Contains(schema.Type, decimalType) { - if schema.Enum != nil && len(schema.Enum) > 0 { - enum := schema.Enum[wr.rand.Int()%len(schema.Enum)] - - var example any - _ = enum.Decode(&example) - - structure[key] = example - } else { - - var minimum int64 = 1 - var maximum int64 = 100 - - if schema.Minimum != nil { - minimum = int64(*schema.Minimum) - } - if schema.Maximum != nil { - maximum = int64(*schema.Maximum) - } - - if schema.Examples != nil { - if len(schema.Examples) > 0 { - var renderedExample any - exmp := schema.Examples[0] - if exmp != nil { - var ex any - _ = exmp.Decode(&ex) - renderedExample = ex - } - structure[key] = renderedExample - return true - } - } - - switch schema.Format { - case floatType: - structure[key] = wr.rand.Float32() - case doubleType: - structure[key] = wr.rand.Float64() - case int32Type: - structure[key] = int(wr.RandomInt(minimum, maximum)) - case bigIntType: - structure[key] = wr.RandomInt(minimum, maximum) - case decimalType: - structure[key] = wr.RandomFloat64() - default: - structure[key] = wr.RandomInt(minimum, maximum) - } - } + structure[key] = wr.renderMockNumberValue(schema) return true } @@ -340,7 +302,7 @@ func (wr *SchemaRenderer) DiveIntoSchema(schema *base.Schema, key string, struct for propName, propValue := range checkProps.FromOldest() { // propValue is nil when a required property is listed but absent from the - // properties map (GetOrZero returns nil). Emit {} — existing behavior. + // properties map. Emit {} to preserve existing behavior. if propValue == nil { propertyMap[propName] = make(map[string]any) continue @@ -357,13 +319,13 @@ func (wr *SchemaRenderer) DiveIntoSchema(schema *base.Schema, key string, struct continue } } else if propValue.IsReference() { - // $ref could not be resolved — emit null placeholder and notify callback + // Emit null for unresolved $ref properties and notify the callback. propertyMap[propName] = nil if wr.onUnresolvedRef != nil { wr.onUnresolvedRef(propName, propValue, propValue.GetBuildError()) } } else { - // Non-reference property with no schema. Emit {} — existing behavior. + // Emit {} for non-reference properties with no schema to preserve existing behavior. propertyMap[propName] = make(map[string]any) } } @@ -554,6 +516,79 @@ func (wr *SchemaRenderer) DiveIntoSchema(schema *base.Schema, key string, struct return true } +func normalizeMockGenerationOptions(options MockGenerationOptions) MockGenerationOptions { + if options.MaxPatternRepeatBudget <= 0 { + options.MaxPatternRepeatBudget = DefaultMaxPatternRepeatBudget + } + if options.MaxGeneratedStringBytes <= 0 { + options.MaxGeneratedStringBytes = DefaultMaxGeneratedStringBytes + } + if options.MaxMockDepth <= 0 { + options.MaxMockDepth = DefaultMaxMockDepth + } + if options.MaxMockNodes <= 0 { + options.MaxMockNodes = DefaultMaxMockNodes + } + if options.MaxMockProperties <= 0 { + options.MaxMockProperties = DefaultMaxMockProperties + } + if options.MaxMockRefExpansions <= 0 { + options.MaxMockRefExpansions = DefaultMaxMockRefExpansions + } + if options.MaxMockBytes <= 0 { + options.MaxMockBytes = DefaultMaxMockBytes + } + return options +} + +func (wr *SchemaRenderer) effectiveMockGenerationOptions() MockGenerationOptions { + if wr == nil { + return normalizeMockGenerationOptions(MockGenerationOptions{}) + } + return normalizeMockGenerationOptions(wr.mockOptions) +} + +func (wr *SchemaRenderer) generatePatternString(pattern string, schemaMaxLength int64, hasSchemaMaxLength bool) (string, error) { + options := wr.effectiveMockGenerationOptions() + repeatBudget := options.MaxPatternRepeatBudget + if hasSchemaMaxLength && schemaMaxLength > 0 && schemaMaxLength < int64(repeatBudget) { + repeatBudget = int(schemaMaxLength) + } + str, err := reggen.Generate(pattern, repeatBudget) + if err != nil { + return "", err + } + return truncateStringBytes(str, options.MaxGeneratedStringBytes), nil +} + +func boundedGeneratedStringRange(minLength, maxLength int64, maxBytes int) (int64, int64) { + if maxBytes <= 0 { + return minLength, maxLength + } + capLength := int64(maxBytes) + if minLength > capLength { + minLength = capLength + } + if maxLength <= 0 || maxLength > capLength { + maxLength = capLength + } + if maxLength < minLength { + maxLength = minLength + } + return minLength, maxLength +} + +func truncateStringBytes(value string, maxBytes int) string { + if maxBytes <= 0 || len(value) <= maxBytes { + return value + } + cut := maxBytes + for cut > 0 && !utf8.RuneStart(value[cut]) { + cut-- + } + return value[:cut] +} + func readFile(file io.ReadCloser) []string { defer file.Close() @@ -572,7 +607,7 @@ func copyMap(m map[string]bool) map[string]bool { return res } -// ReadDictionary will read a dictionary file and return a slice of strings. +// ReadDictionary reads a dictionary file and returns one entry per line. func ReadDictionary(dictionaryLocation string) []string { file, err := os.Open(dictionaryLocation) if err != nil { @@ -581,19 +616,18 @@ func ReadDictionary(dictionaryLocation string) []string { return readFile(file) } -// RandomWord will return a random word from the dictionary file between the min and max values. The depth is used -// to prevent a stack overflow, the maximum depth is 100 (anything more than this is probably a bug). -// set the values to 0 to return the first word returned, essentially ignore the min and max values. +// RandomWord returns a random word between the min and max lengths. +// +// If no dictionary is configured, RandomWord returns a generated alphabetic string. Set min and max to 0 to return the +// selected dictionary word without length filtering. The depth parameter prevents unbounded retries. func (wr *SchemaRenderer) RandomWord(min, max int64, depth int) string { - // break out if we've gone too deep if depth > 100 { return fmt.Sprintf("no-word-found-%d-%d", min, max) } - // no dictionary? then just return a random string. if len(wr.words) == 0 { if min == 0 { - min = 7 // seems like a good default + min = 7 } b := make([]byte, min) for i := range b { @@ -612,7 +646,7 @@ func (wr *SchemaRenderer) RandomWord(min, max int64, depth int) string { return word } -// RandomInt will return a random int between the min and max values. +// RandomInt returns a random integer between min and max. func (wr *SchemaRenderer) RandomInt(min, max int64) int64 { if max <= min { return min @@ -620,12 +654,12 @@ func (wr *SchemaRenderer) RandomInt(min, max int64) int64 { return wr.rand.Int63n(max-min) + min } -// RandomFloat64 will return a random float64 between 0 and 1. +// RandomFloat64 returns a random float64 between 0 and 1. func (wr *SchemaRenderer) RandomFloat64() float64 { return wr.rand.Float64() } -// PseudoUUID will return a random UUID, it's not a real UUID, but it's good enough for mock /example data. +// PseudoUUID returns a UUID-shaped random value for mock data. func (wr *SchemaRenderer) PseudoUUID() string { b := make([]byte, 16) _, _ = cryptoRand.Read(b) diff --git a/renderer/schema_renderer_test.go b/renderer/schema_renderer_test.go index 8533fbdb..ef696a63 100644 --- a/renderer/schema_renderer_test.go +++ b/renderer/schema_renderer_test.go @@ -2288,7 +2288,7 @@ func TestRenderSchema_NestedDeep(t *testing.T) { dive = func(mapNode map[string]any, level int) { child := mapNode["child"] if level >= 100 { - assert.Equal(t, "to deep to continue rendering...", child) + assert.Equal(t, mockDepthExceededPlaceholder, child) journeyLevel = level return }