diff --git a/README.md b/README.md index 11578a1..515a9c7 100644 --- a/README.md +++ b/README.md @@ -1 +1,12 @@ # theschemagen + +Generate OpenAPI Schemas from JSON bodies, Now in GO! + +Roadmap: + +* [x] Generate OpenAPI Schemas from JSON bodies +* [] Generate Path schemas from Full HTTP responses +* [] Generate an accurate schema from multiple distinct responses + * [] oneOf + * [] common component models +* [] Dynamically enhance openapi schemas with additional information from the API responses diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..21f0137 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/lukehagar/theschemagen + +go 1.23.1 + +require ( + github.com/stretchr/testify v1.9.0 + golang.org/x/text v0.18.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b4749ee --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..4e23854 --- /dev/null +++ b/main.go @@ -0,0 +1,195 @@ +package theschemagen + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + "gopkg.in/yaml.v3" +) + +func PrettyPrint(v interface{}, format string) { + var b []byte + var err error + + switch format { + case "json": + b, err = json.MarshalIndent(v, "", " ") + case "yaml": + b, err = yaml.Marshal(v) + default: + b, err = yaml.Marshal(v) + } + + if err != nil { + panic(err) + } + + fmt.Println(string(b)) +} + +type SchemaObject struct { + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty"` + Items interface{} `json:"items,omitempty" yaml:"items,omitempty"` + Examples []interface{} `json:"examples,omitempty" yaml:"examples,omitempty"` + Properties map[string]SchemaObject `json:"properties,omitempty" yaml:"properties,omitempty"` +} + +func ConvertNumber(number float64) SchemaObject { + output := SchemaObject{} + if IsInteger(number) { + output.Type = "integer" + if number < 2147483647 && number > -2147483647 { + output.Format = "int32" + } else if IsSafeInteger(number) { + output.Format = "int64" + } + } else { + output.Type = "number" + } + + output.Examples = []interface{}{number} + + return output +} + +func ConvertArray(array []interface{}) SchemaObject { + output := SchemaObject{Type: "array", Items: nil} + var outputItems []SchemaObject + + for _, entry := range array { + + objectMap := ConvertObject(entry) + isDuplicate := false + + for _, item := range outputItems { + hasSameTypeAndFormat := item.Type == objectMap.Type && item.Format == objectMap.Format + hasSameProperties := item.Properties != nil && objectMap.Properties != nil && + CompareKeys(item.Properties, objectMap.Properties) + if hasSameTypeAndFormat || hasSameProperties { + isDuplicate = true + break + } + } + + if !isDuplicate { + outputItems = append(outputItems, objectMap) + } + + } + + if len(outputItems) > 1 { + output.Items = map[string]interface{}{"oneOf": outputItems} + } else { + output.Items = outputItems[0] + } + + return output +} + +func ConvertString(str string) SchemaObject { + output := SchemaObject{Type: "string"} + + regxDate := regexp.MustCompile(`^(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$`) + regxDateTime := regexp.MustCompile(`^(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([0-1][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]Z$`) + + if regxDateTime.MatchString(str) { + output.Format = "date-time" + } else if regxDate.MatchString(str) { + output.Format = "date" + } + + output.Examples = []interface{}{str} + + return output +} + +func ConvertObject(input interface{}) SchemaObject { + switch v := input.(type) { + case nil: + return SchemaObject{Type: "null"} + case float64: + return ConvertNumber(v) + case []interface{}: + return ConvertArray(v) + case map[string]interface{}: + output := SchemaObject{Type: "object", Properties: make(map[string]SchemaObject)} + for key, val := range v { + output.Properties[key] = ConvertObject(val) + } + return output + case string: + return ConvertString(v) + case bool: + output := SchemaObject{Type: "boolean"} + + output.Examples = []interface{}{v} + + return output + default: + panic("Invalid type for conversion") + } +} + +func ConvertJSONToOAS(input string) SchemaObject { + var obj map[string]interface{} + err := json.Unmarshal([]byte(input), &obj) + if err != nil { + panic(err) + } + return ConvertObject(obj) +} + +func ConvertObjectToOAS(input map[string]interface{}) SchemaObject { + return ConvertObject(input) +} + +var ignoredWords = []string{"the", "a", "an", "of", "to", "in", "for", "with", "on", "at", "from", "by", "and"} + +func ConvertSummaryToOperationId(summary string) string { + words := strings.Split(summary, " ") + var filteredWords []string + for i, word := range words { + if i == 0 { + filteredWords = append(filteredWords, strings.ToLower(string(word[0]))+word[1:]) + } else { + if !Contains(ignoredWords, strings.ToLower(word)) { + filteredWords = append(filteredWords, cases.Title(language.English, cases.NoLower).String(word)) + } + } + } + return strings.Join(filteredWords, "") +} + +func Contains(arr []string, str string) bool { + for _, v := range arr { + if v == str { + return true + } + } + return false +} + +func CompareKeys(m1, m2 map[string]SchemaObject) bool { + if len(m1) != len(m2) { + return false + } + for k := range m1 { + if _, ok := m2[k]; !ok { + return false + } + } + return true +} + +func IsInteger(n float64) bool { + return n == float64(int(n)) +} + +func IsSafeInteger(n float64) bool { + return n <= float64(int64(^uint(0)>>1)) && n >= float64(int64(^uint(0)>>1)*-1) +} diff --git a/tests/convert_test.go b/tests/convert_test.go new file mode 100644 index 0000000..b8199bd --- /dev/null +++ b/tests/convert_test.go @@ -0,0 +1,61 @@ +package theschemagen_test + +import ( + "os" + "testing" + + "github.com/lukehagar/theschemagen" + "github.com/stretchr/testify/require" +) + +func TestConvertJSONToOAS(t *testing.T) { + // assert := assert.New(t) + require := require.New(t) + + testJson, err := os.ReadFile("./test-files/test.json") + if err != nil { + panic(err) + } + + exampleJSON := string(testJson) + + schema := theschemagen.ConvertJSONToOAS(exampleJSON) + // root level check + require.Equal(schema.Type, "object") + + require.Equal(schema.Properties["stringsMock"].Properties["stringTest"].Type, "string") + + require.Equal(schema.Properties["stringsMock"].Properties["isoDate"].Type, "string") + require.Equal(schema.Properties["stringsMock"].Properties["isoDate"].Format, "date") + + require.Equal(schema.Properties["stringsMock"].Properties["isoDateTime"].Type, "string") + require.Equal(schema.Properties["stringsMock"].Properties["isoDateTime"].Format, "date-time") + + require.Equal(schema.Properties["numbersMock"].Properties["smallInt"].Type, "integer") + require.Equal(schema.Properties["numbersMock"].Properties["smallInt"].Format, "int32") +} + +func BenchmarkConvertJSONToOAS(b *testing.B) { + testJson, err := os.ReadFile("./test-files/test.json") + if err != nil { + panic(err) + } + + exampleJSON := string(testJson) + + for i := 0; i < b.N; i++ { + theschemagen.ConvertJSONToOAS(exampleJSON) + } +} + +func TestConvertObject(t *testing.T) { + testJson, err := os.ReadFile("./test-files/test.json") + if err != nil { + panic(err) + } + + exampleJSON := string(testJson) + + schema := theschemagen.ConvertJSONToOAS(exampleJSON) + theschemagen.PrettyPrint(schema, "yaml") +} diff --git a/tests/test-files/test.json b/tests/test-files/test.json new file mode 100644 index 0000000..0beaa4e --- /dev/null +++ b/tests/test-files/test.json @@ -0,0 +1,36 @@ +{ + "numbersMock": { + "smallInt": -20, + "bigInt": 2147483647, + "unsafeInt": 9999999999999999, + "notInt": 12.2 + }, + "stringsMock": { + "stringTest": "Hello World", + "isoDate": "1999-12-31", + "isoDateTime": "1999-12-31T23:59:59Z" + }, + "objectsMock": { + "child": { "child": true }, + "childList": [{ "child": true }], + "childMatrix": [[{ "child": true }]], + "mixedObjectsArray": [ + [1, 2, { "test": true }], + { "child": true }, + { "son": true }, + { "son": true }, + { "offspring": true } + ], + "nullable": null + }, + "listMock": [1, 2, 3, 4, 5], + "matrixMock": [ + [1, 2], + [3, 4] + ], + "mixedArrayMock": [1, "two", 3, "four", 5], + "mixedMatrixMock": [ + [1, "two"], + [3, "four"] + ] +} \ No newline at end of file