diff --git a/cli/library_command.go b/cli/library_command.go index 53354b9..2cb6853 100644 --- a/cli/library_command.go +++ b/cli/library_command.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "fmt" "github.com/choria-io/fisk" @@ -8,6 +9,7 @@ import ( "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" "github.com/synadia-io/connect/model" + "github.com/synadia-io/connect/schema" "os" "strings" @@ -22,6 +24,8 @@ type libraryCommand struct { kind string status string component string + + formatJsonSchema bool } func ConfigureLibraryCommand(parentCmd commandHost, opts *Options) { @@ -48,6 +52,7 @@ func ConfigureLibraryCommand(parentCmd commandHost, opts *Options) { infoCmd.Arg("runtime", "The runtime id").StringVar(&c.runtime) infoCmd.Arg("kind", "The kind of component").EnumVar(&c.kind, kindOpts...) infoCmd.Arg("name", "The name of the component").StringVar(&c.component) + infoCmd.Flag("jsonschema", "Output the component schema in JSON Schema format").UnNegatableBoolVar(&c.formatJsonSchema) } func (c *libraryCommand) listRuntimes(pc *fisk.ParseContext) error { @@ -150,24 +155,45 @@ func (c *libraryCommand) info(pc *fisk.ParseContext) error { os.Exit(1) } - // Component info. - w := table.NewWriter() - w.SetStyle(table.StyleRounded) - w.SetTitle("Component Description") - w.AppendRow(table.Row{"Runtime", component.RuntimeId}) - w.AppendRow(table.Row{"Name", component.Name}) - w.AppendRow(table.Row{"Kind", component.Kind}) - w.AppendRow(table.Row{"Status", component.Status}) - - if component.Description != nil { - w.AppendRow(table.Row{"Description", text.WrapSoft(*component.Description, 75)}) + if component == nil { + color.Red("Component not found") + os.Exit(1) } - result := w.Render() - fmt.Println(result) + if c.formatJsonSchema { + jsch, err := schema.ToJsonSchema(c.runtime, "", c.kind, *component) + if err != nil { + color.Red("Could not convert to JSON Schema: %s", err) + os.Exit(1) + } - for _, field := range component.Fields { - printField(field, "") + jout, err := json.MarshalIndent(jsch, "", " ") + if err != nil { + color.Red("Could not marshal JSON Schema: %s", err) + os.Exit(1) + } + + fmt.Println(string(jout)) + } else { + // Component info. + w := table.NewWriter() + w.SetStyle(table.StyleRounded) + w.SetTitle("Component Description") + w.AppendRow(table.Row{"Runtime", component.RuntimeId}) + w.AppendRow(table.Row{"Name", component.Name}) + w.AppendRow(table.Row{"Kind", component.Kind}) + w.AppendRow(table.Row{"Status", component.Status}) + + if component.Description != nil { + w.AppendRow(table.Row{"Description", text.WrapSoft(*component.Description, 75)}) + } + + result := w.Render() + fmt.Println(result) + + for _, field := range component.Fields { + printField(field, "") + } } return nil diff --git a/go.mod b/go.mod index 6249340..8fa32a6 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/choria-io/fisk v0.7.1 github.com/evanphx/json-patch/v5 v5.9.11 github.com/fatih/color v1.18.0 + github.com/google/jsonschema-go v0.3.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/jedib0t/go-pretty/v6 v6.6.7 github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum index 5afe64b..224eacd 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= diff --git a/schema/jsonschema.go b/schema/jsonschema.go new file mode 100644 index 0000000..4e88800 --- /dev/null +++ b/schema/jsonschema.go @@ -0,0 +1,233 @@ +package schema + +import ( + "encoding/json" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/synadia-io/connect/model" +) + +func ToJsonSchema(runtime string, version string, kind string, cmp model.Component) (*jsonschema.Schema, error) { + s := &jsonschema.Schema{ + ID: fmt.Sprintf("%s.v%s.%s.%s", runtime, version, kind, cmp.Name), + Title: cmp.Label, + Type: "object", + Extra: map[string]any{ + "status": string(cmp.Status), + }, + Properties: map[string]*jsonschema.Schema{}, + Required: []string{}, + } + + if cmp.Description != nil { + s.Description = *cmp.Description + } + + if cmp.Icon != nil { + s.Extra["icon"] = *cmp.Icon + } + + for _, field := range cmp.Fields { + fs, err := fieldSchema(field.Kind, field.Type, field) + if err != nil { + return nil, err + } + + s.Properties[field.Name] = fs + + if field.Optional != nil && !*field.Optional { + s.Required = append(s.Required, field.Name) + } + } + + return s, nil +} + +func fieldSchema(fk model.ComponentFieldKind, ft model.ComponentFieldType, fld model.ComponentField) (*jsonschema.Schema, error) { + switch fk { + case model.ComponentFieldKindScalar: + switch ft { + case model.ComponentFieldTypeString, model.ComponentFieldTypeExpression, model.ComponentFieldTypeCondition: + return stringSchema(fld) + case model.ComponentFieldTypeInt: + return integerSchema(fld) + case model.ComponentFieldTypeBool: + return booleanSchema(fld) + case model.ComponentFieldTypeObject: + return objectSchema(fld) + case model.ComponentFieldTypeScanner: + return scannerSchema(fld) + default: + return nil, fmt.Errorf("unsupported field type: %s", fld.Type) + } + case model.ComponentFieldKindList: + itemSchema, err := fieldSchema(model.ComponentFieldKindScalar, ft, model.ComponentField{ + Type: ft, + Fields: fld.Fields, + }) + if err != nil { + return nil, err + } + + result, err := commonSchema(fld) + if err != nil { + return nil, err + } + + result.Type = "array" + result.Items = itemSchema + return result, nil + + case model.ComponentFieldKindMap: + itemSchema, err := fieldSchema(model.ComponentFieldKindScalar, ft, model.ComponentField{ + Type: ft, + Fields: fld.Fields, + }) + if err != nil { + return nil, err + } + + result, err := commonSchema(fld) + if err != nil { + return nil, err + } + + result.Type = "object" + result.AdditionalProperties = itemSchema + return result, nil + + default: + return nil, fmt.Errorf("unsupported field kind: %s", fk) + } +} + +func commonSchema(fld model.ComponentField) (*jsonschema.Schema, error) { + result := &jsonschema.Schema{ + Title: fld.Label, + Extra: map[string]any{}, + } + + if fld.Description != nil { + result.Description = *fld.Description + } + + if fld.Default != nil { + b, err := json.Marshal(fld.Default) + if err != nil { + return nil, fmt.Errorf("failed to marshal default value: %w", err) + } + + result.Default = b + } + + if fld.Secret != nil { + result.Extra["secret"] = *fld.Secret + } + + if fld.RenderHint != nil { + result.Extra["preset"] = *fld.RenderHint + } + + if fld.Examples != nil { + result.Examples = fld.Examples + } + + return result, nil +} + +func stringSchema(fld model.ComponentField) (*jsonschema.Schema, error) { + s, err := commonSchema(fld) + if err != nil { + return nil, err + } + + s.Type = "string" + + switch fld.Type { + case model.ComponentFieldTypeExpression: + s.Extra["render_hint"] = "expression" + case model.ComponentFieldTypeCondition: + s.Extra["render_hint"] = "condition" + } + + for _, constraint := range fld.Constraints { + // -- add enum values + if constraint.Enum != nil { + for _, v := range constraint.Enum { + s.Enum = append(s.Enum, v) + } + } + + if constraint.Preset != nil { + s.Extra["preset"] = constraint.Preset + } + // todo: add other constraints + } + + return s, nil +} + +func booleanSchema(fld model.ComponentField) (*jsonschema.Schema, error) { + s, err := commonSchema(fld) + if err != nil { + return nil, err + } + + s.Type = "boolean" + + return s, nil +} + +func integerSchema(fld model.ComponentField) (*jsonschema.Schema, error) { + s, err := commonSchema(fld) + if err != nil { + return nil, err + } + + s.Type = "integer" + + // todo: add range constraints + + return s, nil +} + +func objectSchema(fld model.ComponentField) (*jsonschema.Schema, error) { + s, err := commonSchema(fld) + if err != nil { + return nil, err + } + + s.Type = "object" + s.Properties = map[string]*jsonschema.Schema{} + + // add the child fields + for _, childFld := range fld.Fields { + childSchema, err := fieldSchema(childFld.Kind, childFld.Type, *childFld) + if err != nil { + return nil, fmt.Errorf("failed to convert field %s to jsonschema: %w", childFld.Name, err) + } + + s.Properties[childFld.Name] = childSchema + + if childFld.Optional != nil && !*childFld.Optional { + s.Required = append(s.Required, childFld.Name) + } + } + + return s, nil +} + +func scannerSchema(fld model.ComponentField) (*jsonschema.Schema, error) { + s, err := commonSchema(fld) + if err != nil { + return nil, err + } + + s.Type = "object" + s.AdditionalProperties = &jsonschema.Schema{ + Type: "object", + } + + return s, nil +}