test(plugins): add unit tests for rustOutputType and isPrimitiveRustType functions

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-31 12:58:48 -05:00
parent 169bbb9d0a
commit aa03d53524
3 changed files with 830 additions and 308 deletions

View File

@ -469,6 +469,10 @@ func rustConstType(goType string) string {
// rustOutputType converts a Go type to Rust for capability method signatures.
// It handles pointer types specially - for capability outputs, pointers become the base type
// (not Option<T>) because Rust's Result<T, Error> already provides optional semantics.
//
// TODO: Pointer to primitive types (e.g., *string, *int32) are not handled correctly.
// Currently "*string" returns "string" instead of "String". This would generate invalid
// Rust code. No current capability uses this pattern, but it should be fixed if needed.
func rustOutputType(goType string) string {
// Strip pointer prefix - capability outputs use Result<T, Error> for optionality
if strings.HasPrefix(goType, "*") {

View File

@ -955,6 +955,206 @@ type OnInitOutput struct {
})
})
var _ = Describe("Rust Generation", func() {
Describe("rustOutputType", func() {
It("should convert Go primitives to Rust primitives", func() {
Expect(rustOutputType("bool")).To(Equal("bool"))
Expect(rustOutputType("string")).To(Equal("String"))
Expect(rustOutputType("int")).To(Equal("i32"))
Expect(rustOutputType("int32")).To(Equal("i32"))
Expect(rustOutputType("int64")).To(Equal("i64"))
Expect(rustOutputType("float32")).To(Equal("f32"))
Expect(rustOutputType("float64")).To(Equal("f64"))
})
It("should strip pointer prefix", func() {
// NOTE: This behavior is incorrect for pointer to primitives.
// "*string" returns "string" instead of "String", which would generate
// invalid Rust code. No current capability uses this pattern.
// See TODO in rustOutputType function.
Expect(rustOutputType("*string")).To(Equal("string"))
Expect(rustOutputType("*MyStruct")).To(Equal("MyStruct"))
})
It("should pass through unknown types", func() {
Expect(rustOutputType("CustomType")).To(Equal("CustomType"))
Expect(rustOutputType("MyStruct")).To(Equal("MyStruct"))
})
})
Describe("isPrimitiveRustType", func() {
It("should return true for primitive Go types", func() {
Expect(isPrimitiveRustType("bool")).To(BeTrue())
Expect(isPrimitiveRustType("string")).To(BeTrue())
Expect(isPrimitiveRustType("int")).To(BeTrue())
Expect(isPrimitiveRustType("int32")).To(BeTrue())
Expect(isPrimitiveRustType("int64")).To(BeTrue())
Expect(isPrimitiveRustType("float32")).To(BeTrue())
Expect(isPrimitiveRustType("float64")).To(BeTrue())
})
It("should return false for non-primitive types", func() {
Expect(isPrimitiveRustType("MyStruct")).To(BeFalse())
Expect(isPrimitiveRustType("CustomType")).To(BeFalse())
Expect(isPrimitiveRustType("[]string")).To(BeFalse())
Expect(isPrimitiveRustType("map[string]int")).To(BeFalse())
})
It("should handle pointer types by stripping prefix", func() {
Expect(isPrimitiveRustType("*string")).To(BeTrue())
Expect(isPrimitiveRustType("*int64")).To(BeTrue())
Expect(isPrimitiveRustType("*MyStruct")).To(BeFalse())
})
})
Describe("GenerateCapabilityRust", func() {
It("should generate valid Rust code with primitive output types", func() {
cap := Capability{
Name: "test",
Interface: "TestAgent",
Required: true,
SourceFile: "test",
Methods: []Export{
{
Name: "GetBool",
ExportName: "nd_get_bool",
Input: Param{Type: "BoolInput"},
Output: Param{Type: "bool"},
},
{
Name: "GetString",
ExportName: "nd_get_string",
Input: Param{Type: "StrInput"},
Output: Param{Type: "string"},
},
{
Name: "GetInt",
ExportName: "nd_get_int",
Input: Param{Type: "IntInput"},
Output: Param{Type: "int32"},
},
},
Structs: []StructDef{
{Name: "BoolInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
{Name: "StrInput", Fields: []FieldDef{{Name: "Key", Type: "string", JSONTag: "key"}}},
{Name: "IntInput", Fields: []FieldDef{{Name: "Index", Type: "int32", JSONTag: "index"}}},
},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check that primitive output types are not prefixed with $crate::
// The template should use isPrimitiveRust to determine this
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<bool>>"))
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<String>>"))
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<i32>>"))
// Verify that primitive output types don't use $crate:: prefix in FnResult
// The pattern "$crate::test::bool>" would indicate incorrect generation
Expect(codeStr).NotTo(ContainSubstring("$crate::test::bool>"))
Expect(codeStr).NotTo(ContainSubstring("$crate::test::String>"))
Expect(codeStr).NotTo(ContainSubstring("$crate::test::i32>"))
})
It("should generate valid Rust code with struct output types", func() {
cap := Capability{
Name: "metadata",
Interface: "MetadataAgent",
Required: true,
SourceFile: "metadata",
Methods: []Export{
{
Name: "GetArtist",
ExportName: "nd_get_artist",
Input: Param{Type: "ArtistInput"},
Output: Param{Type: "ArtistOutput"},
},
},
Structs: []StructDef{
{Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
{Name: "ArtistOutput", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}},
},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Non-primitive struct types should use $crate:: prefix
Expect(codeStr).To(ContainSubstring("$crate::metadata::ArtistOutput"))
})
It("should generate valid Rust code with pointer output types", func() {
cap := Capability{
Name: "test",
Interface: "TestAgent",
Required: true,
SourceFile: "test",
Methods: []Export{
{
Name: "GetOptionalStruct",
ExportName: "nd_get_optional_struct",
Input: Param{Type: "Input"},
Output: Param{Type: "*Output"},
},
},
Structs: []StructDef{
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
{Name: "Output", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Pointer to struct should strip pointer and use struct type with $crate::
Expect(codeStr).To(ContainSubstring("$crate::test::Output>"))
// Pointer output types should NOT have Option<> wrapping - Result handles optionality
Expect(codeStr).NotTo(ContainSubstring("Option<"))
})
It("should include all float types correctly", func() {
cap := Capability{
Name: "test",
Interface: "TestAgent",
Required: true,
SourceFile: "test",
Methods: []Export{
{
Name: "GetFloat32",
ExportName: "nd_get_float32",
Input: Param{Type: "Input"},
Output: Param{Type: "float32"},
},
{
Name: "GetFloat64",
ExportName: "nd_get_float64",
Input: Param{Type: "Input"},
Output: Param{Type: "float64"},
},
},
Structs: []StructDef{
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<f32>>"))
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<f64>>"))
})
})
})
func writeFile(path, content string) error {
return os.WriteFile(path, []byte(content), 0600)
}

View File

@ -1,373 +1,691 @@
package internal
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
)
func TestGenerateSchema(t *testing.T) {
tests := []struct {
name string
capability Capability
wantErr bool
validate func(t *testing.T, schema []byte)
}{
{
name: "basic capability with one export",
capability: Capability{
Name: "test",
Doc: "Test capability",
SourceFile: "test",
Methods: []Export{
{
ExportName: "test_method",
Doc: "Test method does something",
Input: NewParam("input", "TestInput"),
Output: NewParam("output", "TestOutput"),
},
},
Structs: []StructDef{
{
Name: "TestInput",
Doc: "Input for test",
Fields: []FieldDef{
{Name: "Name", Type: "string", JSONTag: "name", Doc: "The name"},
{Name: "Count", Type: "int", JSONTag: "count", Doc: "The count"},
var _ = Describe("XTP Schema Generation", func() {
parseSchema := func(schema []byte) map[string]any {
var doc map[string]any
Expect(yaml.Unmarshal(schema, &doc)).To(Succeed())
return doc
}
Describe("GenerateSchema", func() {
Context("basic capability with one export", func() {
var schema []byte
BeforeEach(func() {
capability := Capability{
Name: "test",
Doc: "Test capability",
SourceFile: "test",
Methods: []Export{
{
ExportName: "test_method",
Doc: "Test method does something",
Input: NewParam("input", "TestInput"),
Output: NewParam("output", "TestOutput"),
},
},
{
Name: "TestOutput",
Doc: "Output for test",
Fields: []FieldDef{
{Name: "Result", Type: "string", JSONTag: "result", Doc: "The result"},
Structs: []StructDef{
{
Name: "TestInput",
Doc: "Input for test",
Fields: []FieldDef{
{Name: "Name", Type: "string", JSONTag: "name", Doc: "The name"},
{Name: "Count", Type: "int", JSONTag: "count", Doc: "The count"},
},
},
{
Name: "TestOutput",
Doc: "Output for test",
Fields: []FieldDef{
{Name: "Result", Type: "string", JSONTag: "result", Doc: "The result"},
},
},
},
},
},
validate: func(t *testing.T, schema []byte) {
var doc map[string]any
require.NoError(t, yaml.Unmarshal(schema, &doc))
}
var err error
schema, err = GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(schema).NotTo(BeEmpty())
})
// Check version
assert.Equal(t, "v1-draft", doc["version"])
It("should have correct version", func() {
doc := parseSchema(schema)
Expect(doc["version"]).To(Equal("v1-draft"))
})
// Check exports
It("should include exports with description", func() {
doc := parseSchema(schema)
exports := doc["exports"].(map[string]any)
assert.Contains(t, exports, "test_method")
Expect(exports).To(HaveKey("test_method"))
method := exports["test_method"].(map[string]any)
assert.Equal(t, "Test method does something", method["description"])
Expect(method["description"]).To(Equal("Test method does something"))
})
// Check schemas
It("should include schemas for input and output types", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
assert.Contains(t, schemas, "TestInput")
assert.Contains(t, schemas, "TestOutput")
Expect(schemas).To(HaveKey("TestInput"))
Expect(schemas).To(HaveKey("TestOutput"))
})
// Check TestInput schema
It("should define input schema with correct properties", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["TestInput"].(map[string]any)
assert.Equal(t, "object", input["type"]) // Workaround for XTP code generator
Expect(input["type"]).To(Equal("object")) // Workaround for XTP code generator
props := input["properties"].(map[string]any)
assert.Contains(t, props, "name")
assert.Contains(t, props, "count")
Expect(props).To(HaveKey("name"))
Expect(props).To(HaveKey("count"))
})
// Check required fields (non-pointer, non-omitempty)
It("should mark non-pointer, non-omitempty fields as required", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["TestInput"].(map[string]any)
required := input["required"].([]any)
assert.Contains(t, required, "name")
assert.Contains(t, required, "count")
},
},
{
name: "capability with pointer fields (nullable)",
capability: Capability{
Name: "nullable_test",
SourceFile: "nullable_test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{
Name: "Input",
Fields: []FieldDef{
{Name: "Required", Type: "string", JSONTag: "required"},
{Name: "Optional", Type: "*string", JSONTag: "optional,omitempty", OmitEmpty: true},
},
},
{
Name: "Output",
Fields: []FieldDef{
{Name: "Value", Type: "string", JSONTag: "value"},
},
},
},
},
validate: func(t *testing.T, schema []byte) {
var doc map[string]any
require.NoError(t, yaml.Unmarshal(schema, &doc))
Expect(required).To(ContainElement("name"))
Expect(required).To(ContainElement("count"))
})
})
Context("capability with pointer fields (nullable)", func() {
var schema []byte
BeforeEach(func() {
capability := Capability{
Name: "nullable_test",
SourceFile: "nullable_test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{
Name: "Input",
Fields: []FieldDef{
{Name: "Required", Type: "string", JSONTag: "required"},
{Name: "Optional", Type: "*string", JSONTag: "optional,omitempty", OmitEmpty: true},
},
},
{
Name: "Output",
Fields: []FieldDef{
{Name: "Value", Type: "string", JSONTag: "value"},
},
},
},
}
var err error
schema, err = GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
})
It("should not mark required field as nullable", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["Input"].(map[string]any)
props := input["properties"].(map[string]any)
// Required field is not nullable
requiredField := props["required"].(map[string]any)
assert.NotContains(t, requiredField, "nullable")
// Optional pointer field is nullable
optionalField := props["optional"].(map[string]any)
assert.Equal(t, true, optionalField["nullable"])
// Check required array only has non-pointer fields
required := input["required"].([]any)
assert.Contains(t, required, "required")
assert.NotContains(t, required, "optional")
},
},
{
name: "capability with enum",
capability: Capability{
Name: "enum_test",
SourceFile: "enum_test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{
Name: "Input",
Fields: []FieldDef{
{Name: "Status", Type: "Status", JSONTag: "status"},
},
},
{
Name: "Output",
Fields: []FieldDef{
{Name: "Value", Type: "string", JSONTag: "value"},
},
},
},
TypeAliases: []TypeAlias{
{Name: "Status", Type: "string", Doc: "Status type"},
},
Consts: []ConstGroup{
{
Type: "Status",
Values: []ConstDef{
{Name: "StatusPending", Value: `"pending"`},
{Name: "StatusActive", Value: `"active"`},
{Name: "StatusDone", Value: `"done"`},
},
},
},
},
validate: func(t *testing.T, schema []byte) {
var doc map[string]any
require.NoError(t, yaml.Unmarshal(schema, &doc))
Expect(requiredField).NotTo(HaveKey("nullable"))
})
It("should mark optional pointer field as nullable", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["Input"].(map[string]any)
props := input["properties"].(map[string]any)
optionalField := props["optional"].(map[string]any)
Expect(optionalField["nullable"]).To(BeTrue())
})
// Check enum is defined
assert.Contains(t, schemas, "Status")
It("should only include non-pointer fields in required array", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["Input"].(map[string]any)
required := input["required"].([]any)
Expect(required).To(ContainElement("required"))
Expect(required).NotTo(ContainElement("optional"))
})
})
Context("capability with enum", func() {
var schema []byte
BeforeEach(func() {
capability := Capability{
Name: "enum_test",
SourceFile: "enum_test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{
Name: "Input",
Fields: []FieldDef{
{Name: "Status", Type: "Status", JSONTag: "status"},
},
},
{
Name: "Output",
Fields: []FieldDef{
{Name: "Value", Type: "string", JSONTag: "value"},
},
},
},
TypeAliases: []TypeAlias{
{Name: "Status", Type: "string", Doc: "Status type"},
},
Consts: []ConstGroup{
{
Type: "Status",
Values: []ConstDef{
{Name: "StatusPending", Value: `"pending"`},
{Name: "StatusActive", Value: `"active"`},
{Name: "StatusDone", Value: `"done"`},
},
},
},
}
var err error
schema, err = GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
})
It("should define enum type with correct values", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
Expect(schemas).To(HaveKey("Status"))
status := schemas["Status"].(map[string]any)
assert.Equal(t, "string", status["type"])
Expect(status["type"]).To(Equal("string"))
enum := status["enum"].([]any)
assert.ElementsMatch(t, []any{"pending", "active", "done"}, enum)
Expect(enum).To(ConsistOf("pending", "active", "done"))
})
// Check $ref in Input
It("should use $ref for enum field in struct", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["Input"].(map[string]any)
props := input["properties"].(map[string]any)
statusRef := props["status"].(map[string]any)
assert.Equal(t, "#/components/schemas/Status", statusRef["$ref"])
},
},
{
name: "capability with array types",
capability: Capability{
Name: "array_test",
SourceFile: "array_test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{
Name: "Input",
Fields: []FieldDef{
{Name: "Tags", Type: "[]string", JSONTag: "tags"},
{Name: "Items", Type: "[]Item", JSONTag: "items"},
},
},
{
Name: "Output",
Fields: []FieldDef{
{Name: "Value", Type: "string", JSONTag: "value"},
},
},
{
Name: "Item",
Fields: []FieldDef{
{Name: "ID", Type: "string", JSONTag: "id"},
},
},
},
},
validate: func(t *testing.T, schema []byte) {
var doc map[string]any
require.NoError(t, yaml.Unmarshal(schema, &doc))
Expect(statusRef["$ref"]).To(Equal("#/components/schemas/Status"))
})
})
Context("capability with array types", func() {
var schema []byte
BeforeEach(func() {
capability := Capability{
Name: "array_test",
SourceFile: "array_test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{
Name: "Input",
Fields: []FieldDef{
{Name: "Tags", Type: "[]string", JSONTag: "tags"},
{Name: "Items", Type: "[]Item", JSONTag: "items"},
},
},
{
Name: "Output",
Fields: []FieldDef{
{Name: "Value", Type: "string", JSONTag: "value"},
},
},
{
Name: "Item",
Fields: []FieldDef{
{Name: "ID", Type: "string", JSONTag: "id"},
},
},
},
}
var err error
schema, err = GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
})
It("should define string array with primitive type", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["Input"].(map[string]any)
props := input["properties"].(map[string]any)
// Check string array
tags := props["tags"].(map[string]any)
assert.Equal(t, "array", tags["type"])
Expect(tags["type"]).To(Equal("array"))
tagItems := tags["items"].(map[string]any)
assert.Equal(t, "string", tagItems["type"])
Expect(tagItems["type"]).To(Equal("string"))
})
// Check struct array (uses $ref)
It("should define struct array with $ref", func() {
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["Input"].(map[string]any)
props := input["properties"].(map[string]any)
items := props["items"].(map[string]any)
assert.Equal(t, "array", items["type"])
Expect(items["type"]).To(Equal("array"))
itemItems := items["items"].(map[string]any)
assert.Equal(t, "#/components/schemas/Item", itemItems["$ref"])
},
},
{
name: "capability with nullable ref",
capability: Capability{
Name: "nullable_ref_test",
SourceFile: "nullable_ref_test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{
Name: "Input",
Fields: []FieldDef{
{Name: "Value", Type: "string", JSONTag: "value"},
},
},
{
Name: "Output",
Fields: []FieldDef{
{Name: "Status", Type: "*ErrorType", JSONTag: "status,omitempty", OmitEmpty: true},
},
},
},
TypeAliases: []TypeAlias{
{Name: "ErrorType", Type: "string"},
},
Consts: []ConstGroup{
{
Type: "ErrorType",
Values: []ConstDef{
{Name: "ErrorNone", Value: `"none"`},
{Name: "ErrorFatal", Value: `"fatal"`},
},
},
},
},
validate: func(t *testing.T, schema []byte) {
var doc map[string]any
require.NoError(t, yaml.Unmarshal(schema, &doc))
Expect(itemItems["$ref"]).To(Equal("#/components/schemas/Item"))
})
})
Context("capability with nullable ref", func() {
It("should mark pointer to enum as nullable with $ref", func() {
capability := Capability{
Name: "nullable_ref_test",
SourceFile: "nullable_ref_test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{
Name: "Input",
Fields: []FieldDef{
{Name: "Value", Type: "string", JSONTag: "value"},
},
},
{
Name: "Output",
Fields: []FieldDef{
{Name: "Status", Type: "*ErrorType", JSONTag: "status,omitempty", OmitEmpty: true},
},
},
},
TypeAliases: []TypeAlias{
{Name: "ErrorType", Type: "string"},
},
Consts: []ConstGroup{
{
Type: "ErrorType",
Values: []ConstDef{
{Name: "ErrorNone", Value: `"none"`},
{Name: "ErrorFatal", Value: `"fatal"`},
},
},
},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
output := schemas["Output"].(map[string]any)
props := output["properties"].(map[string]any)
// Pointer to enum type should have $ref AND nullable
status := props["status"].(map[string]any)
assert.Equal(t, "#/components/schemas/ErrorType", status["$ref"])
assert.Equal(t, true, status["nullable"])
Expect(status["$ref"]).To(Equal("#/components/schemas/ErrorType"))
Expect(status["nullable"]).To(BeTrue())
})
})
})
Describe("goTypeToXTPTypeAndFormat", func() {
DescribeTable("should convert Go types to XTP types",
func(goType, wantType, wantFormat string) {
gotType, gotFormat := goTypeToXTPTypeAndFormat(goType)
Expect(gotType).To(Equal(wantType))
Expect(gotFormat).To(Equal(wantFormat))
},
},
}
Entry("string", "string", "string", ""),
Entry("int", "int", "integer", "int32"),
Entry("int32", "int32", "integer", "int32"),
Entry("int64", "int64", "integer", "int64"),
Entry("float32", "float32", "number", "float"),
Entry("float64", "float64", "number", "float"),
Entry("bool", "bool", "boolean", ""),
Entry("[]byte", "[]byte", "string", "byte"),
Entry("unknown types default to object", "CustomType", "object", ""),
)
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schema, err := GenerateSchema(tt.capability)
if tt.wantErr {
assert.Error(t, err)
return
Describe("cleanDocForYAML", func() {
DescribeTable("should clean documentation strings",
func(doc, want string) {
Expect(cleanDocForYAML(doc)).To(Equal(want))
},
Entry("empty", "", ""),
Entry("single line", "Simple description", "Simple description"),
Entry("multiline", "First line\nSecond line", "First line\nSecond line"),
Entry("trailing newline", "Description\n", "Description"),
Entry("whitespace", " Description ", "Description"),
)
})
Describe("isPrimitiveGoType", func() {
DescribeTable("should identify primitive Go types",
func(goType string, want bool) {
Expect(isPrimitiveGoType(goType)).To(Equal(want))
},
Entry("bool", "bool", true),
Entry("string", "string", true),
Entry("int", "int", true),
Entry("int32", "int32", true),
Entry("int64", "int64", true),
Entry("float32", "float32", true),
Entry("float64", "float64", true),
Entry("[]byte", "[]byte", true),
Entry("custom type", "CustomType", false),
Entry("struct type", "MyStruct", false),
Entry("slice of string", "[]string", false),
Entry("map type", "map[string]int", false),
)
})
Describe("GenerateSchema with primitive output types", func() {
inputStruct := StructDef{
Name: "Input",
Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}},
}
Context("export with primitive string output", func() {
It("should use type instead of $ref", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
Methods: []Export{
{ExportName: "get_name", Input: NewParam("input", "Input"), Output: NewParam("output", "string")},
},
Structs: []StructDef{inputStruct},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
Expect(schema).NotTo(BeEmpty())
doc := parseSchema(schema)
exports := doc["exports"].(map[string]any)
method := exports["get_name"].(map[string]any)
output := method["output"].(map[string]any)
Expect(output["type"]).To(Equal("string"))
Expect(output).NotTo(HaveKey("$ref"))
Expect(output["contentType"]).To(Equal("application/json"))
})
})
Context("export with primitive bool output", func() {
It("should use boolean type", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
Methods: []Export{
{ExportName: "is_valid", Input: NewParam("input", "Input"), Output: NewParam("output", "bool")},
},
Structs: []StructDef{inputStruct},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
doc := parseSchema(schema)
exports := doc["exports"].(map[string]any)
method := exports["is_valid"].(map[string]any)
output := method["output"].(map[string]any)
Expect(output["type"]).To(Equal("boolean"))
Expect(output).NotTo(HaveKey("$ref"))
})
})
Context("export with primitive int output", func() {
It("should use integer type", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
Methods: []Export{
{ExportName: "get_count", Input: NewParam("input", "Input"), Output: NewParam("output", "int32")},
},
Structs: []StructDef{inputStruct},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
doc := parseSchema(schema)
exports := doc["exports"].(map[string]any)
method := exports["get_count"].(map[string]any)
output := method["output"].(map[string]any)
Expect(output["type"]).To(Equal("integer"))
Expect(output).NotTo(HaveKey("$ref"))
})
})
Context("export with pointer to primitive output", func() {
It("should strip pointer and use primitive type", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
Methods: []Export{
{ExportName: "get_optional_string", Input: NewParam("input", "Input"), Output: NewParam("output", "*string")},
},
Structs: []StructDef{inputStruct},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
doc := parseSchema(schema)
exports := doc["exports"].(map[string]any)
method := exports["get_optional_string"].(map[string]any)
output := method["output"].(map[string]any)
Expect(output["type"]).To(Equal("string"))
Expect(output).NotTo(HaveKey("$ref"))
})
})
Context("export with struct output", func() {
It("should still use $ref", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
Methods: []Export{
{ExportName: "get_result", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
inputStruct,
{Name: "Output", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
doc := parseSchema(schema)
exports := doc["exports"].(map[string]any)
method := exports["get_result"].(map[string]any)
output := method["output"].(map[string]any)
Expect(output["$ref"]).To(Equal("#/components/schemas/Output"))
Expect(output).NotTo(HaveKey("type"))
})
})
})
Describe("collectUsedTypes", func() {
getSchemas := func(schema []byte) map[string]any {
doc := parseSchema(schema)
components, hasComponents := doc["components"].(map[string]any)
if !hasComponents {
return make(map[string]any)
}
require.NoError(t, err)
assert.NotEmpty(t, schema)
if tt.validate != nil {
tt.validate(t, schema)
schemas, ok := components["schemas"].(map[string]any)
if !ok {
return make(map[string]any)
}
return schemas
}
It("should only include types referenced by exports", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "UsedInput"), Output: NewParam("output", "UsedOutput")},
},
Structs: []StructDef{
{Name: "UsedInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
{Name: "UsedOutput", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
{Name: "UnusedStruct", Fields: []FieldDef{{Name: "Foo", Type: "string", JSONTag: "foo"}}},
},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
schemas := getSchemas(schema)
Expect(schemas).To(HaveKey("UsedInput"))
Expect(schemas).To(HaveKey("UsedOutput"))
Expect(schemas).NotTo(HaveKey("UnusedStruct"))
})
}
}
func TestGoTypeToXTPTypeAndFormat(t *testing.T) {
tests := []struct {
goType string
wantType string
wantFormat string
}{
{"string", "string", ""},
{"int", "integer", "int32"},
{"int32", "integer", "int32"},
{"int64", "integer", "int64"},
{"float32", "number", "float"},
{"float64", "number", "float"},
{"bool", "boolean", ""},
{"[]byte", "string", "byte"},
// Unknown types default to object
{"CustomType", "object", ""},
}
It("should include transitively referenced types", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
{Name: "Output", Fields: []FieldDef{{Name: "Nested", Type: "NestedType", JSONTag: "nested"}}},
{Name: "NestedType", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
for _, tt := range tests {
t.Run(tt.goType, func(t *testing.T) {
gotType, gotFormat := goTypeToXTPTypeAndFormat(tt.goType)
assert.Equal(t, tt.wantType, gotType)
assert.Equal(t, tt.wantFormat, gotFormat)
schemas := getSchemas(schema)
Expect(schemas).To(HaveKey("Input"))
Expect(schemas).To(HaveKey("Output"))
Expect(schemas).To(HaveKey("NestedType"))
})
}
}
func TestCleanDocForYAML(t *testing.T) {
tests := []struct {
name string
doc string
want string
}{
{
name: "empty",
doc: "",
want: "",
},
{
name: "single line",
doc: "Simple description",
want: "Simple description",
},
{
name: "multiline",
doc: "First line\nSecond line",
want: "First line\nSecond line",
},
{
name: "trailing newline",
doc: "Description\n",
want: "Description",
},
{
name: "whitespace",
doc: " Description ",
want: "Description",
},
}
It("should include array element types", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
{Name: "Output", Fields: []FieldDef{{Name: "Items", Type: "[]Item", JSONTag: "items"}}},
{Name: "Item", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}},
},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := cleanDocForYAML(tt.doc)
assert.Equal(t, tt.want, got)
schemas := getSchemas(schema)
Expect(schemas).To(HaveKey("Input"))
Expect(schemas).To(HaveKey("Output"))
Expect(schemas).To(HaveKey("Item"))
})
}
}
It("should include pointer types", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
{Name: "Output", Fields: []FieldDef{{Name: "Optional", Type: "*OptionalType", JSONTag: "optional"}}},
{Name: "OptionalType", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
schemas := getSchemas(schema)
Expect(schemas).To(HaveKey("Input"))
Expect(schemas).To(HaveKey("Output"))
Expect(schemas).To(HaveKey("OptionalType"))
})
It("should exclude primitive output types from schema", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "string")},
},
Structs: []StructDef{
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
schemas := getSchemas(schema)
Expect(schemas).To(HaveKey("Input"))
})
})
Describe("GenerateSchema enum filtering", func() {
It("should only include enums that are actually used by exports", func() {
capability := Capability{
Name: "test",
SourceFile: "test",
Methods: []Export{
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
},
Structs: []StructDef{
{
Name: "Input",
Fields: []FieldDef{{Name: "Status", Type: "UsedStatus", JSONTag: "status"}},
},
{
Name: "Output",
Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}},
},
},
TypeAliases: []TypeAlias{
{Name: "UsedStatus", Type: "string"},
{Name: "UnusedStatus", Type: "string"},
},
Consts: []ConstGroup{
{
Type: "UsedStatus",
Values: []ConstDef{
{Name: "StatusActive", Value: `"active"`},
{Name: "StatusInactive", Value: `"inactive"`},
},
},
{
Type: "UnusedStatus",
Values: []ConstDef{
{Name: "UnusedPending", Value: `"pending"`},
},
},
},
}
schema, err := GenerateSchema(capability)
Expect(err).NotTo(HaveOccurred())
doc := parseSchema(schema)
components := doc["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
// UsedStatus should be included because it's referenced by Input
Expect(schemas).To(HaveKey("UsedStatus"))
usedStatus := schemas["UsedStatus"].(map[string]any)
Expect(usedStatus["type"]).To(Equal("string"))
enum := usedStatus["enum"].([]any)
Expect(enum).To(ConsistOf("active", "inactive"))
// UnusedStatus should NOT be included
Expect(schemas).NotTo(HaveKey("UnusedStatus"))
})
})
})