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 }