diff --git a/cmd/umctl/cmd/umodel.go b/cmd/umctl/cmd/umodel.go index fc97979..242fbf6 100644 --- a/cmd/umctl/cmd/umodel.go +++ b/cmd/umctl/cmd/umodel.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "net/http" + "path/filepath" "strconv" "strings" @@ -11,6 +12,7 @@ import ( "github.com/alibaba/UnifiedModel/cmd/umctl/pkg/hint" "github.com/alibaba/UnifiedModel/cmd/umctl/pkg/registry" "github.com/alibaba/UnifiedModel/cmd/umctl/pkg/response" + "github.com/alibaba/UnifiedModel/internal/umodel/payload" ) var umodelCmd = &cobra.Command{ @@ -51,11 +53,7 @@ var umodelValidateCmd = &cobra.Command{ file = rest[0] } requireFile(file) - raw, err := readRawFile(file) - if err != nil { - hint.HandleInputError(err, file) - } - raw, err = wrapPayload(raw, "elements") + raw, err := readUModelValidatePayload(file) if err != nil { hint.HandleInputError(err, file) } @@ -135,7 +133,7 @@ func init() { addWorkspaceFlag(umodelImportCmd, umodelValidateCmd, umodelPutCmd, umodelDeleteCmd, umodelExportCmd) umodelImportCmd.Flags().String("path", "", "Path to YAML/JSON file or directory") - umodelValidateCmd.Flags().StringP("file", "f", "", "JSON file with UModel elements") + umodelValidateCmd.Flags().StringP("file", "f", "", "JSON elements payload or single UModel YAML file") umodelPutCmd.Flags().StringP("file", "f", "", "JSON file with UModel elements") umodelDeleteCmd.Flags().String("ids", "", "Comma-separated element IDs") umodelExportCmd.Flags().Int("limit", 1000, "Maximum number of elements to export") @@ -153,7 +151,7 @@ func init() { Description: "Validate UModel elements", Params: []registry.ParamMeta{ {Name: "workspace", Type: "string", Flag: "--workspace", Short: "-w", Description: "Target workspace", Required: true}, - {Name: "file", Type: "string", Flag: "--file", Short: "-f", Description: "JSON file with elements", Required: true}, + {Name: "file", Type: "string", Flag: "--file", Short: "-f", Description: "JSON elements payload or single UModel YAML file", Required: true}, }, }) registry.Global.Register(registry.CommandMeta{ @@ -199,6 +197,29 @@ func buildIDsPayload(ids, reason string) []byte { return b } +func readUModelValidatePayload(file string) ([]byte, error) { + raw, err := readRawFile(file) + if err != nil { + return nil, err + } + if !isYAMLPath(file) { + return wrapPayload(raw, "elements") + } + element, err := payload.ParseElementBytes(raw, filepath.Ext(file)) + if err != nil { + return nil, err + } + return marshalJSONBytes(map[string]any{"elements": []any{element}}) +} + +func isYAMLPath(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".yaml", ".yml": + return true + default: + return false + } +} func requireWorkspace(ws string) { if ws == "" { response.ExitWithError(response.ExitParam, "Missing --workspace / -w flag", "Specify the target workspace.") diff --git a/cmd/umctl/cmd/umodel_yaml_test.go b/cmd/umctl/cmd/umodel_yaml_test.go new file mode 100644 index 0000000..3792d06 --- /dev/null +++ b/cmd/umctl/cmd/umodel_yaml_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "net/http" + "os" + "path/filepath" + "testing" +) + +func TestUModelValidateAcceptsSchemaStyleYAMLFile(t *testing.T) { + yamlPath := filepath.Join(t.TempDir(), "service.yaml") + if err := os.WriteFile(yamlPath, []byte(` +kind: entity_set +schema: + version: v0.1.0 +metadata: + name: devops.service + domain: devops +spec: + fields: + - name: id + type: string +`), 0o644); err != nil { + t.Fatalf("write yaml: %v", err) + } + + cleanup := setupTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertMethod(t, r, http.MethodPost) + assertPath(t, r, "/api/v1/umodel/demo/validate") + body := decodeBody(t, r) + elements, ok := body["elements"].([]any) + if !ok || len(elements) != 1 { + t.Fatalf("expected one normalized element, got %+v", body) + } + element, ok := elements[0].(map[string]any) + if !ok { + t.Fatalf("expected object element, got %+v", elements[0]) + } + assertField(t, element, "kind", "entity_set") + assertField(t, element, "domain", "devops") + assertField(t, element, "name", "devops.service") + assertField(t, element, "version", "v0.1.0") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"valid":true}`)) + })) + defer cleanup() + + out, code := captureStdoutAndExitCode(t, func() { + rootCmd.SetArgs([]string{"umodel", "validate", "demo", yamlPath}) + rootCmd.Execute() + }) + if code != 0 { + t.Fatalf("exit code = %d, output = %s", code, out) + } +} diff --git a/docs/en/reference/cli.md b/docs/en/reference/cli.md index 4aaa8a0..5c21c4b 100644 --- a/docs/en/reference/cli.md +++ b/docs/en/reference/cli.md @@ -110,13 +110,13 @@ go run ./cmd/umctl --addr http://localhost:8080 workspace delete demo ## UModel ```bash -go run ./cmd/umctl --addr http://localhost:8080 umodel validate demo examples/quickstart-multidomain/devops/entity_set/devops.service.yaml +go run ./cmd/umctl --addr http://localhost:8080 umodel validate demo examples/quickstart-multidomain/umodel/devops/entity_set/devops.service.yaml go run ./cmd/umctl --addr http://localhost:8080 umodel put demo '{"kind":"entity_set","domain":"devops","name":"devops.service"}' go run ./cmd/umctl --addr http://localhost:8080 umodel import demo examples/quickstart-multidomain go run ./cmd/umctl --addr http://localhost:8080 umodel export demo 100 ``` -`umodel put` and `umodel validate` accept a JSON object, a JSON array, or a JSON payload with an `elements` field. +`umodel validate` accepts a JSON object, JSON array, JSON payload with an `elements` field, or one UModel YAML source file. `umodel put` accepts JSON only: an object, array, or payload with an `elements` field. ## EntityStore diff --git a/docs/zh/reference/cli.md b/docs/zh/reference/cli.md index a60a805..1d3e4fa 100644 --- a/docs/zh/reference/cli.md +++ b/docs/zh/reference/cli.md @@ -111,13 +111,13 @@ go run ./cmd/umctl --addr http://localhost:8080 workspace delete demo ## UModel ```bash -go run ./cmd/umctl --addr http://localhost:8080 umodel validate demo examples/quickstart-multidomain/devops/entity_set/devops.service.yaml +go run ./cmd/umctl --addr http://localhost:8080 umodel validate demo examples/quickstart-multidomain/umodel/devops/entity_set/devops.service.yaml go run ./cmd/umctl --addr http://localhost:8080 umodel put demo '{"kind":"entity_set","domain":"devops","name":"devops.service"}' go run ./cmd/umctl --addr http://localhost:8080 umodel import demo examples/quickstart-multidomain go run ./cmd/umctl --addr http://localhost:8080 umodel export demo 100 ``` -`umodel put` 和 `umodel validate` 接受 JSON object、JSON array,或包含 `elements` 字段的 payload。 +`umodel validate` 接受 JSON object、JSON array、包含 `elements` 字段的 JSON payload,或单个 UModel YAML 源文件。`umodel put` 只接受 JSON:object、array,或包含 `elements` 字段的 payload。 ## EntityStore diff --git a/internal/umodel/import.go b/internal/umodel/import.go index 6aebbfc..8e647bf 100644 --- a/internal/umodel/import.go +++ b/internal/umodel/import.go @@ -2,16 +2,14 @@ package umodel import ( "context" - "encoding/json" - "fmt" "os" "path/filepath" "sort" "strings" + "github.com/alibaba/UnifiedModel/internal/umodel/payload" apperrors "github.com/alibaba/UnifiedModel/pkg/errors" "github.com/alibaba/UnifiedModel/pkg/model" - "gopkg.in/yaml.v3" ) // Import loads UModel elements from an operator-provided path. The path is @@ -78,7 +76,7 @@ func (s *Service) importInternal(ctx context.Context, workspace string, req mode } path = resolved } - element, err := parseUModelElementFile(path) + element, err := payload.ParseElementFile(path) if err != nil { return result, apperrors.WithDetails(apperrors.CodeValidationFailed, "umodel import failed", map[string]string{ "path": path, @@ -249,112 +247,3 @@ func isImportFile(path string) bool { return false } } - -func parseUModelElementFile(path string) (model.UModelElement, error) { - body, err := os.ReadFile(path) - if err != nil { - return model.UModelElement{}, err - } - var payload map[string]any - switch strings.ToLower(filepath.Ext(path)) { - case ".json": - if err := json.Unmarshal(body, &payload); err != nil { - return model.UModelElement{}, err - } - case ".yaml", ".yml": - if err := yaml.Unmarshal(body, &payload); err != nil { - return model.UModelElement{}, err - } - default: - return model.UModelElement{}, fmt.Errorf("unsupported file extension") - } - return elementFromPayload(normalizeMap(payload)) -} - -func elementFromPayload(payload map[string]any) (model.UModelElement, error) { - metadata := nestedMap(payload, "metadata") - schema := nestedMap(payload, "schema") - - kind := firstString(payload["kind"]) - name := firstString(metadata["name"], payload["name"]) - domain := firstString(metadata["domain"], payload["domain"]) - version := firstString(schema["version"], payload["version"]) - if domain == "" { - domain = inferDomain(name) - } - if kind == "" { - return model.UModelElement{}, fmt.Errorf("kind is required") - } - if domain == "" { - return model.UModelElement{}, fmt.Errorf("domain or metadata.domain is required") - } - if name == "" { - return model.UModelElement{}, fmt.Errorf("name or metadata.name is required") - } - - spec := nestedMap(payload, "spec") - return model.UModelElement{ - Kind: kind, - Domain: domain, - Name: name, - Version: version, - Spec: spec, - }, nil -} - -func normalizeMap(source map[string]any) map[string]any { - if source == nil { - return nil - } - out := make(map[string]any, len(source)) - for key, value := range source { - out[key] = normalizeValue(value) - } - return out -} - -func normalizeValue(value any) any { - switch typed := value.(type) { - case map[string]any: - return normalizeMap(typed) - case map[any]any: - out := make(map[string]any, len(typed)) - for key, value := range typed { - out[fmt.Sprint(key)] = normalizeValue(value) - } - return out - case []any: - out := make([]any, len(typed)) - for i, item := range typed { - out[i] = normalizeValue(item) - } - return out - default: - return typed - } -} - -func nestedMap(source map[string]any, key string) map[string]any { - value, ok := source[key].(map[string]any) - if !ok { - return nil - } - return value -} - -func firstString(values ...any) string { - for _, value := range values { - text, ok := value.(string) - if ok && text != "" { - return text - } - } - return "" -} - -func inferDomain(name string) string { - if before, _, ok := strings.Cut(name, "."); ok { - return before - } - return "" -} diff --git a/internal/umodel/payload/payload.go b/internal/umodel/payload/payload.go new file mode 100644 index 0000000..429dc15 --- /dev/null +++ b/internal/umodel/payload/payload.go @@ -0,0 +1,128 @@ +package payload + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/alibaba/UnifiedModel/pkg/model" + "gopkg.in/yaml.v3" +) + +// ParseElementFile reads one UModel source file and normalizes its +// schema/metadata envelope into the public UModelElement shape. +func ParseElementFile(path string) (model.UModelElement, error) { + body, err := os.ReadFile(path) + if err != nil { + return model.UModelElement{}, err + } + return ParseElementBytes(body, filepath.Ext(path)) +} + +// ParseElementBytes normalizes one JSON or YAML UModel source document. +func ParseElementBytes(body []byte, ext string) (model.UModelElement, error) { + var payload map[string]any + switch strings.ToLower(ext) { + case ".json": + if err := json.Unmarshal(body, &payload); err != nil { + return model.UModelElement{}, err + } + case ".yaml", ".yml": + if err := yaml.Unmarshal(body, &payload); err != nil { + return model.UModelElement{}, err + } + default: + return model.UModelElement{}, fmt.Errorf("unsupported file extension") + } + return elementFromMap(normalizeMap(payload)) +} + +func elementFromMap(payload map[string]any) (model.UModelElement, error) { + metadata := nestedMap(payload, "metadata") + schema := nestedMap(payload, "schema") + + kind := firstString(payload["kind"]) + name := firstString(metadata["name"], payload["name"]) + domain := firstString(metadata["domain"], payload["domain"]) + version := firstString(schema["version"], payload["version"]) + if domain == "" { + domain = inferDomain(name) + } + if kind == "" { + return model.UModelElement{}, fmt.Errorf("kind is required") + } + if domain == "" { + return model.UModelElement{}, fmt.Errorf("domain or metadata.domain is required") + } + if name == "" { + return model.UModelElement{}, fmt.Errorf("name or metadata.name is required") + } + + spec := nestedMap(payload, "spec") + return model.UModelElement{ + Kind: kind, + Domain: domain, + Name: name, + Version: version, + Spec: spec, + }, nil +} + +func normalizeMap(source map[string]any) map[string]any { + if source == nil { + return nil + } + out := make(map[string]any, len(source)) + for key, value := range source { + out[key] = normalizeValue(value) + } + return out +} + +func normalizeValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return normalizeMap(typed) + case map[any]any: + out := make(map[string]any, len(typed)) + for key, value := range typed { + out[fmt.Sprint(key)] = normalizeValue(value) + } + return out + case []any: + out := make([]any, len(typed)) + for i, item := range typed { + out[i] = normalizeValue(item) + } + return out + default: + return typed + } +} + +func nestedMap(source map[string]any, key string) map[string]any { + value, ok := source[key].(map[string]any) + if !ok { + return nil + } + return value +} + +func firstString(values ...any) string { + for _, value := range values { + text, ok := value.(string) + if ok && text != "" { + return text + } + } + return "" +} + +func inferDomain(name string) string { + if before, _, ok := strings.Cut(name, "."); ok { + return before + } + return "" +} diff --git a/internal/umodel/payload/payload_test.go b/internal/umodel/payload/payload_test.go new file mode 100644 index 0000000..139f0f6 --- /dev/null +++ b/internal/umodel/payload/payload_test.go @@ -0,0 +1,42 @@ +package payload + +import "testing" + +func TestParseElementBytesNormalizesSchemaStyleYAML(t *testing.T) { + element, err := ParseElementBytes([]byte(` +kind: entity_set +schema: + version: v0.1.0 +metadata: + name: devops.service + domain: devops +spec: + fields: + - name: id + type: string +`), ".yaml") + if err != nil { + t.Fatalf("parse yaml: %v", err) + } + if element.Kind != "entity_set" || element.Domain != "devops" || element.Name != "devops.service" || element.Version != "v0.1.0" { + t.Fatalf("unexpected element envelope: %+v", element) + } + fields, ok := element.Spec["fields"].([]any) + if !ok || len(fields) != 1 { + t.Fatalf("expected spec.fields to survive normalization, got %+v", element.Spec) + } +} + +func TestParseElementBytesInfersDomainFromName(t *testing.T) { + element, err := ParseElementBytes([]byte(` +kind: entity_set +metadata: + name: devops.service +`), ".yaml") + if err != nil { + t.Fatalf("parse yaml: %v", err) + } + if element.Domain != "devops" { + t.Fatalf("domain = %q, want devops", element.Domain) + } +} diff --git a/internal/umodel/schemaspec/validator.go b/internal/umodel/schemaspec/validator.go index cd96eeb..1fe4dcc 100644 --- a/internal/umodel/schemaspec/validator.go +++ b/internal/umodel/schemaspec/validator.go @@ -30,7 +30,7 @@ func NewNoopValidator() *Validator { // unknown to the registry; field-level problems are reported in Result. // // TODO(v2): also validate the kind/schema/metadata envelope (currently -// elementFromPayload validates the minimum imperatively before the element +// the UModel payload normalizer validates the minimum imperatively before the element // reaches this layer). func (v *Validator) Validate(element model.UModelElement) (Result, error) { if v == nil || v.registry == nil {