Initial basic schema functionality

This commit is contained in:
Luke Hagar
2024-09-30 15:32:17 +00:00
parent 2465256c6d
commit 8e0b950f23
6 changed files with 329 additions and 0 deletions

View File

@@ -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

14
go.mod Normal file
View File

@@ -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
)

12
go.sum Normal file
View File

@@ -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=

195
main.go Normal file
View File

@@ -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)
}

61
tests/convert_test.go Normal file
View File

@@ -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")
}

View File

@@ -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"]
]
}