// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT package renderer import ( cryptoRand "crypto/rand" "encoding/base64" "fmt" "io" "math/rand" "os" "strings" "time" "github.com/lucasjones/reggen" "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/orderedmap" "golang.org/x/exp/slices" ) const rootType = "rootType" const stringType = "string" const numberType = "number" const integerType = "integer" const booleanType = "boolean" const objectType = "object" const arrayType = "array" const int32Type = "int32" const floatType = "float" const doubleType = "double" const byteType = "byte" const binaryType = "binary" const passwordType = "password" const dateType = "date" const dateTimeType = "date-time" const timeType = "time" const emailType = "email" const hostnameType = "hostname" const ipv4Type = "ipv4" const ipv6Type = "ipv6" const uriType = "uri" const uriReferenceType = "uri-reference" const uuidType = "uuid" const allOfType = "allOf" const anyOfType = "anyOf" const oneOfType = "oneOf" const itemsType = "items" // used to generate random words if there is no dictionary applied. const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" func init() { // create a new random seed rand.New(rand.NewSource(time.Now().UnixNano())) } // 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. type SchemaRenderer struct { words []string } // CreateRendererUsingDictionary will create a new 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} } // CreateRendererUsingDefaultDictionary will create a new 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. func CreateRendererUsingDefaultDictionary() *SchemaRenderer { wr := new(SchemaRenderer) wr.words = ReadDictionary("/usr/share/dict/words") return wr } // RenderSchema takes a schema and renders it into an interface, ready to be converted to JSON or YAML. func (wr *SchemaRenderer) RenderSchema(schema *base.Schema) any { // dive into the schema and render it structure := make(map[string]any) wr.DiveIntoSchema(schema, rootType, structure, 0) return structure[rootType].(any) } // 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. func (wr *SchemaRenderer) DiveIntoSchema(schema *base.Schema, key string, structure map[string]any, depth int) { // got an example? use it, we're done here. if schema.Example != nil { structure[key] = schema.Example return } // emergency break to prevent stack overflow from ever occurring if depth > 100 { structure[key] = "to deep to continue rendering..." return } // 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 { structure[key] = schema.Enum[rand.Int()%len(schema.Enum)] } 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 } 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", rand.Int()%255, rand.Int()%255, rand.Int()%255, rand.Int()%255) case ipv6Type: structure[key] = fmt.Sprintf("%04x:%04x:%04x:%04x:%04x:%04x:%04x:%04x", rand.Intn(65535), rand.Intn(65535), rand.Intn(65535), rand.Intn(65535), rand.Intn(65535), rand.Intn(65535), rand.Intn(65535), 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] = fmt.Sprintf("%x", wr.RandomWord(minLength, maxLength, 0)) case passwordType: structure[key] = fmt.Sprintf("%s", wr.RandomWord(minLength, maxLength, 0)) case binaryType: structure[key] = fmt.Sprintf("%s", base64.StdEncoding.EncodeToString([]byte(wr.RandomWord(minLength, maxLength, 0)))) 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 { structure[key] = wr.RandomWord(minLength, maxLength, 0) } } } return } // handle numbers if slices.Contains(schema.Type, numberType) || slices.Contains(schema.Type, integerType) { if schema.Enum != nil && len(schema.Enum) > 0 { structure[key] = schema.Enum[rand.Int()%len(schema.Enum)] } 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) } switch schema.Format { case floatType: structure[key] = rand.Float32() case doubleType: structure[key] = rand.Float64() case int32Type: structure[key] = int(wr.RandomInt(minimum, maximum)) default: structure[key] = wr.RandomInt(minimum, maximum) } } return } // handle booleans if slices.Contains(schema.Type, booleanType) { structure[key] = true } // handle objects if slices.Contains(schema.Type, objectType) { properties := schema.Properties propertyMap := make(map[string]any) if properties != nil { // check if this schema has required properties, if so, then only render required props, if not // render everything in the schema. checkProps := orderedmap.New[string, *base.SchemaProxy]() if len(schema.Required) > 0 { for _, requiredProp := range schema.Required { checkProps.Set(requiredProp, properties.GetOrZero(requiredProp)) } } else { checkProps = properties } for pair := orderedmap.First(checkProps); pair != nil; pair = pair.Next() { // render property propName, propValue := pair.Key(), pair.Value() propertySchema := propValue.Schema() wr.DiveIntoSchema(propertySchema, propName, propertyMap, depth+1) } } // handle allOf allOf := schema.AllOf if allOf != nil { allOfMap := make(map[string]any) for _, allOfSchema := range allOf { allOfCompiled := allOfSchema.Schema() wr.DiveIntoSchema(allOfCompiled, allOfType, allOfMap, depth+1) for k, v := range allOfMap[allOfType].(map[string]any) { propertyMap[k] = v } } } // handle dependentSchemas dependentSchemas := schema.DependentSchemas if dependentSchemas != nil { dependentSchemasMap := make(map[string]any) for pair := orderedmap.First(dependentSchemas); pair != nil; pair = pair.Next() { // only map if the property exists k, dependentSchema := pair.Key(), pair.Value() if propertyMap[k] != nil { dependentSchemaCompiled := dependentSchema.Schema() wr.DiveIntoSchema(dependentSchemaCompiled, k, dependentSchemasMap, depth+1) for i, v := range dependentSchemasMap[k].(map[string]any) { propertyMap[k].(map[string]any)[i] = v } } } } // handle oneOf oneOf := schema.OneOf if oneOf != nil { oneOfMap := make(map[string]any) for _, oneOfSchema := range oneOf { oneOfCompiled := oneOfSchema.Schema() wr.DiveIntoSchema(oneOfCompiled, oneOfType, oneOfMap, depth+1) for k, v := range oneOfMap[oneOfType].(map[string]any) { propertyMap[k] = v } break // one run once for the first result. } } // handle anyOf anyOf := schema.AnyOf if anyOf != nil { anyOfMap := make(map[string]any) for _, anyOfSchema := range anyOf { anyOfCompiled := anyOfSchema.Schema() wr.DiveIntoSchema(anyOfCompiled, anyOfType, anyOfMap, depth+1) for k, v := range anyOfMap[anyOfType].(map[string]any) { propertyMap[k] = v } break // one run once for the first result only, same as oneOf } } structure[key] = propertyMap return } if slices.Contains(schema.Type, arrayType) { // an array needs an items schema itemsSchema := schema.Items if itemsSchema != nil { // otherwise the items value is a schema, so we need to dive into it if itemsSchema.IsA() { // check if the schema contains a minItems value and render up to that number. var minItems int64 = 1 if schema.MinItems != nil { minItems = *schema.MinItems } var renderedItems []any // build up the array for i := int64(0); i < minItems; i++ { itemMap := make(map[string]any) itemsSchemaCompiled := itemsSchema.A.Schema() wr.DiveIntoSchema(itemsSchemaCompiled, itemsType, itemMap, depth+1) renderedItems = append(renderedItems, itemMap[itemsType]) } structure[key] = renderedItems return } } } } func readFile(file io.Reader) []string { bytes, err := io.ReadAll(file) if err != nil { return []string{} } return strings.Split(string(bytes), "\n") } // ReadDictionary will read a dictionary file and return a slice of strings. func ReadDictionary(dictionaryLocation string) []string { file, err := os.Open(dictionaryLocation) if err != nil { return []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. 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 } b := make([]byte, min) for i := range b { b[i] = letterBytes[rand.Intn(len(letterBytes))] } return string(b) } word := wr.words[rand.Int()%len(wr.words)] if min == 0 && max == 0 { return word } if len(word) < int(min) || len(word) > int(max) { return wr.RandomWord(min, max, depth+1) } return word } // RandomInt will return a random int between the min and max values. func (wr *SchemaRenderer) RandomInt(min, max int64) int64 { return rand.Int63n(max-min) + min } // RandomFloat64 will return a random float64 between 0 and 1. func (wr *SchemaRenderer) RandomFloat64() float64 { return rand.Float64() } // PseudoUUID will return a random UUID, it's not a real UUID, but it's good enough for mock /example data. func (wr *SchemaRenderer) PseudoUUID() string { b := make([]byte, 16) _, _ = cryptoRand.Read(b) return strings.ToLower(fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])) }