From 3ab1807b1597a6a1004c1143449b5febc763ea47 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Thu, 11 Jun 2026 15:18:35 +0300 Subject: [PATCH] docgen: map info, examples, externalDocs from schema YAML The offline validator now parses schema-level info and per-field examples/externalDocs into concrete types instead of accepting them as untyped any, and schemaFromYAML maps them into the docgen schema so that decree docgen --file renders the metadata it previously dropped. The doc model also gains the two missing spec keys: VersionDescription renders on the version line and the allowed_schemes constraint renders in the constraints section. Named examples now render sorted by name so the generated Markdown is deterministic. Closes #911 Co-Authored-By: Claude Fable 5 --- cmd/decree/docgen.go | 59 ++++++++++--- cmd/decree/helpers_test.go | 123 +++++++++++++++++++++++++++ sdk/tools/docgen/docgen.go | 44 +++++++--- sdk/tools/docgen/docgen_test.go | 54 +++++++++++- sdk/tools/validate/validate.go | 81 ++++++++++++------ sdk/tools/validate/validate_test.go | 126 ++++++++++++++++++++++++++++ 6 files changed, 433 insertions(+), 54 deletions(-) diff --git a/cmd/decree/docgen.go b/cmd/decree/docgen.go index 51c2ac2f..7ac56094 100644 --- a/cmd/decree/docgen.go +++ b/cmd/decree/docgen.go @@ -136,9 +136,11 @@ func schemaFromYAML(data []byte) (*docgen.Schema, error) { return nil, fmt.Errorf("invalid schema YAML: %w", err) } s := &docgen.Schema{ - Name: sf.Name, - Description: sf.Description, - Version: sf.Version, + Name: sf.Name, + Description: sf.Description, + Version: sf.Version, + VersionDescription: sf.VersionDescription, + Info: schemaInfoFromYAML(sf.Info), } for path, f := range sf.Fields { df := docgen.Field{ @@ -157,20 +159,53 @@ func schemaFromYAML(data []byte) (*docgen.Schema, error) { WriteOnce: f.WriteOnce, Sensitive: f.Sensitive, } + if len(f.Examples) > 0 { + df.Examples = make(map[string]docgen.FieldExample, len(f.Examples)) + for name, ex := range f.Examples { + df.Examples[name] = docgen.FieldExample{Value: ex.Value, Summary: ex.Summary} + } + } + if f.ExternalDocs != nil { + df.ExternalDocs = &docgen.ExternalDocs{ + Description: f.ExternalDocs.Description, + URL: f.ExternalDocs.URL, + } + } if f.Constraints != nil { df.Constraints = &docgen.Constraints{ - Min: f.Constraints.Minimum, - Max: f.Constraints.Maximum, - ExclusiveMin: f.Constraints.ExclusiveMinimum, - ExclusiveMax: f.Constraints.ExclusiveMaximum, - MinLength: f.Constraints.MinLength, - MaxLength: f.Constraints.MaxLength, - Pattern: f.Constraints.Pattern, - Enum: f.Constraints.Enum, - JSONSchema: f.Constraints.JSONSchema, + Min: f.Constraints.Minimum, + Max: f.Constraints.Maximum, + ExclusiveMin: f.Constraints.ExclusiveMinimum, + ExclusiveMax: f.Constraints.ExclusiveMaximum, + MinLength: f.Constraints.MinLength, + MaxLength: f.Constraints.MaxLength, + Pattern: f.Constraints.Pattern, + Enum: f.Constraints.Enum, + JSONSchema: f.Constraints.JSONSchema, + AllowedSchemes: f.Constraints.AllowedSchemes, } } s.Fields = append(s.Fields, df) } return s, nil } + +// schemaInfoFromYAML converts the validate info metadata to the docgen shape. +func schemaInfoFromYAML(info *validate.SchemaInfoDef) *docgen.SchemaInfo { + if info == nil { + return nil + } + di := &docgen.SchemaInfo{ + Title: info.Title, + Author: info.Author, + Labels: info.Labels, + } + if info.Contact != nil { + di.Contact = &docgen.SchemaContact{ + Name: info.Contact.Name, + Email: info.Contact.Email, + URL: info.Contact.URL, + } + } + return di +} diff --git a/cmd/decree/helpers_test.go b/cmd/decree/helpers_test.go index 8324c018..91aab6a9 100644 --- a/cmd/decree/helpers_test.go +++ b/cmd/decree/helpers_test.go @@ -356,6 +356,129 @@ fields: } } +const metadataSchemaYAML = `spec_version: v1 +name: payments +version: 3 +version_description: Added refund_window field +info: + title: Payments Configuration + author: platform-team + contact: + name: Pat + email: pat@example.com + labels: + team: platform +fields: + payments.fee: + type: number + examples: + low: + value: "0.01" + summary: Low rate + high: + value: "0.99" + externalDocs: + description: Fee guide + url: https://docs.example.com/fees + payments.webhook: + type: url + constraints: + allowed_schemes: [https, sftp] +` + +func TestSchemaFromYAML_Metadata(t *testing.T) { + s, err := schemaFromYAML([]byte(metadataSchemaYAML)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := s.VersionDescription; got != "Added refund_window field" { + t.Errorf("got %v, want %v", got, "Added refund_window field") + } + if s.Info == nil { + t.Fatal("expected non-nil Info") + } + want := &docgen.SchemaInfo{ + Title: "Payments Configuration", + Author: "platform-team", + Contact: &docgen.SchemaContact{Name: "Pat", Email: "pat@example.com"}, + Labels: map[string]string{"team": "platform"}, + } + if !reflect.DeepEqual(s.Info, want) { + t.Errorf("got %+v, want %+v", s.Info, want) + } + + var fee, webhook *docgen.Field + for i := range s.Fields { + switch s.Fields[i].Path { + case "payments.fee": + fee = &s.Fields[i] + case "payments.webhook": + webhook = &s.Fields[i] + } + } + if fee == nil || webhook == nil { + t.Fatal("expected both fields to be mapped") + } + wantExamples := map[string]docgen.FieldExample{ + "low": {Value: "0.01", Summary: "Low rate"}, + "high": {Value: "0.99"}, + } + if !reflect.DeepEqual(fee.Examples, wantExamples) { + t.Errorf("got %+v, want %+v", fee.Examples, wantExamples) + } + wantDocs := &docgen.ExternalDocs{Description: "Fee guide", URL: "https://docs.example.com/fees"} + if !reflect.DeepEqual(fee.ExternalDocs, wantDocs) { + t.Errorf("got %+v, want %+v", fee.ExternalDocs, wantDocs) + } + if webhook.Constraints == nil || !reflect.DeepEqual(webhook.Constraints.AllowedSchemes, []string{"https", "sftp"}) { + t.Errorf("unexpected constraints: %+v", webhook.Constraints) + } +} + +func TestSchemaFromYAML_InfoWithoutContact(t *testing.T) { + yaml := `spec_version: v1 +name: test +info: + title: Test +fields: + x: + type: string +` + s, err := schemaFromYAML([]byte(yaml)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.Info == nil || s.Info.Title != "Test" { + t.Fatalf("unexpected Info: %+v", s.Info) + } + if s.Info.Contact != nil { + t.Errorf("expected nil Contact, got %+v", s.Info.Contact) + } +} + +func TestSchemaFromYAML_MetadataRenders(t *testing.T) { + s, err := schemaFromYAML([]byte(metadataSchemaYAML)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + md := docgen.Generate(*s) + for _, substr := range []string{ + "# Payments Configuration", + "**Version:** 3 — Added refund_window field", + "**Author:** platform-team", + "**Contact:** Pat ", + "`team: platform`", + "- **low:** `0.01` — Low rate", + "- **high:** `0.99`", + "**See also:** [Fee guide](https://docs.example.com/fees)", + "- Allowed schemes: https, sftp", + } { + if !strings.Contains(md, substr) { + t.Errorf("expected output to contain %q, got:\n%s", substr, md) + } + } +} + func TestSchemaFromYAML_Invalid(t *testing.T) { _, err := schemaFromYAML([]byte("not: [valid")) if err == nil { diff --git a/sdk/tools/docgen/docgen.go b/sdk/tools/docgen/docgen.go index 00a816e2..2e58a6da 100644 --- a/sdk/tools/docgen/docgen.go +++ b/sdk/tools/docgen/docgen.go @@ -18,8 +18,11 @@ type Schema struct { // Version is the schema version number. A value of 0 means the version is // not set and will be omitted from the generated documentation. Version int32 - Info *SchemaInfo - Fields []Field + // VersionDescription describes what changed in this version. It annotates + // the version line, so it is only rendered when Version is set. + VersionDescription string + Info *SchemaInfo + Fields []Field } // SchemaInfo contains optional schema-level metadata. @@ -72,15 +75,16 @@ type ExternalDocs struct { // Constraints defines validation rules for a field. type Constraints struct { - Min *float64 - Max *float64 - ExclusiveMin *float64 - ExclusiveMax *float64 - MinLength *int32 - MaxLength *int32 - Pattern string - Enum []string - JSONSchema string + Min *float64 + Max *float64 + ExclusiveMin *float64 + ExclusiveMax *float64 + MinLength *int32 + MaxLength *int32 + Pattern string + Enum []string + JSONSchema string + AllowedSchemes []string } // Option configures documentation generation. @@ -148,7 +152,11 @@ func Generate(schema Schema, opts ...Option) string { fmt.Fprintf(&b, "%s\n\n", schema.Description) } if schema.Version > 0 { - fmt.Fprintf(&b, "**Version:** %d\n\n", schema.Version) + if schema.VersionDescription != "" { + fmt.Fprintf(&b, "**Version:** %d — %s\n\n", schema.Version, schema.VersionDescription) + } else { + fmt.Fprintf(&b, "**Version:** %d\n\n", schema.Version) + } } if schema.Info != nil { writeSchemaInfo(&b, schema.Info) @@ -255,7 +263,14 @@ func writeField(b *strings.Builder, f Field, cfg *config) { } if len(f.Examples) > 0 { fmt.Fprintln(b, "**Examples:**") - for name, ex := range f.Examples { + // Sort names so the output is deterministic across runs. + names := make([]string, 0, len(f.Examples)) + for name := range f.Examples { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + ex := f.Examples[name] if ex.Summary != "" { fmt.Fprintf(b, "- **%s:** `%s` — %s\n", name, ex.Value, ex.Summary) } else { @@ -309,6 +324,9 @@ func writeConstraints(b *strings.Builder, c *Constraints) { if c.JSONSchema != "" { lines = append(lines, "JSON Schema: (see schema definition)") } + if len(c.AllowedSchemes) > 0 { + lines = append(lines, fmt.Sprintf("Allowed schemes: %s", strings.Join(c.AllowedSchemes, ", "))) + } if len(lines) > 0 { fmt.Fprintln(b, "**Constraints:**") diff --git a/sdk/tools/docgen/docgen_test.go b/sdk/tools/docgen/docgen_test.go index 03a30b27..3d81c7ad 100644 --- a/sdk/tools/docgen/docgen_test.go +++ b/sdk/tools/docgen/docgen_test.go @@ -27,6 +27,31 @@ func TestGenerate_Basic(t *testing.T) { assertContains(t, md, "Fee percentage") } +func TestGenerate_VersionDescription(t *testing.T) { + schema := Schema{ + Name: "test", + Version: 3, + VersionDescription: "Added refund_window field", + Fields: []Field{{Path: "x", Type: "string"}}, + } + + md := Generate(schema) + assertContains(t, md, "**Version:** 3 — Added refund_window field") +} + +func TestGenerate_VersionDescriptionWithoutVersion(t *testing.T) { + schema := Schema{ + Name: "test", + VersionDescription: "Added refund_window field", + Fields: []Field{{Path: "x", Type: "string"}}, + } + + md := Generate(schema) + if strings.Contains(md, "Version") || strings.Contains(md, "Added refund_window field") { + t.Errorf("expected no version line when Version is unset, got:\n%s", md) + } +} + func TestGenerate_GroupsByPrefix(t *testing.T) { schema := Schema{ Name: "test", @@ -61,21 +86,43 @@ func TestGenerate_WithoutGrouping(t *testing.T) { func TestGenerate_Constraints(t *testing.T) { min := float64(0) max := float64(100) + exclMin := float64(0) + exclMax := float64(1) minLen := int32(1) + maxLen := int32(50) schema := Schema{ Name: "test", Fields: []Field{ {Path: "rate", Type: "number", Constraints: &Constraints{Min: &min, Max: &max}}, - {Path: "name", Type: "string", Constraints: &Constraints{MinLength: &minLen, Pattern: "^[a-z]+$", Enum: []string{"a", "b"}}}, + {Path: "share", Type: "number", Constraints: &Constraints{ExclusiveMin: &exclMin, ExclusiveMax: &exclMax}}, + {Path: "name", Type: "string", Constraints: &Constraints{MinLength: &minLen, MaxLength: &maxLen, Pattern: "^[a-z]+$", Enum: []string{"a", "b"}}}, + {Path: "payload", Type: "json", Constraints: &Constraints{JSONSchema: `{"type": "object"}`}}, }, } md := Generate(schema) assertContains(t, md, "Minimum: 0") assertContains(t, md, "Maximum: 100") + assertContains(t, md, "Exclusive minimum: 0") + assertContains(t, md, "Exclusive maximum: 1") assertContains(t, md, "Min length: 1") + assertContains(t, md, "Max length: 50") assertContains(t, md, "Pattern: `^[a-z]+$`") assertContains(t, md, "Enum: a, b") + assertContains(t, md, "JSON Schema: (see schema definition)") +} + +func TestGenerate_AllowedSchemes(t *testing.T) { + schema := Schema{ + Name: "test", + Fields: []Field{ + {Path: "hook", Type: "url", Constraints: &Constraints{AllowedSchemes: []string{"https", "sftp"}}}, + }, + } + + md := Generate(schema) + assertContains(t, md, "**Constraints:**") + assertContains(t, md, "- Allowed schemes: https, sftp") } func TestGenerate_WithoutConstraints(t *testing.T) { @@ -250,9 +297,8 @@ func TestGenerate_Examples(t *testing.T) { } md := Generate(schema) - assertContains(t, md, "**Examples:**") - assertContains(t, md, "**low:** `0.01` — Low rate") - assertContains(t, md, "**high:** `0.99`") + // Examples render sorted by name for deterministic output. + assertContains(t, md, "**Examples:**\n- **high:** `0.99`\n- **low:** `0.01` — Low rate\n") } func TestGenerate_ExternalDocs(t *testing.T) { diff --git a/sdk/tools/validate/validate.go b/sdk/tools/validate/validate.go index bf7565f7..6322f964 100644 --- a/sdk/tools/validate/validate.go +++ b/sdk/tools/validate/validate.go @@ -25,35 +25,46 @@ import ( // SchemaFile is the parsed representation of a schema YAML file. type SchemaFile struct { - SpecVersion string `yaml:"spec_version"` - Schema string `yaml:"$schema,omitempty"` - ID string `yaml:"$id,omitempty"` - Name string `yaml:"name"` - Description string `yaml:"description,omitempty"` - Version int32 `yaml:"version,omitempty"` - VersionDescription string `yaml:"version_description,omitempty"` - // Info is accepted for forward-compatibility with richer schema formats but - // is not used by the offline validator. - Info any `yaml:"info,omitempty"` - Fields map[string]FieldDef `yaml:"fields"` + SpecVersion string `yaml:"spec_version"` + Schema string `yaml:"$schema,omitempty"` + ID string `yaml:"$id,omitempty"` + Name string `yaml:"name"` + Description string `yaml:"description,omitempty"` + Version int32 `yaml:"version,omitempty"` + VersionDescription string `yaml:"version_description,omitempty"` + Info *SchemaInfoDef `yaml:"info,omitempty"` + Fields map[string]FieldDef `yaml:"fields"` +} + +// SchemaInfoDef contains optional schema-level metadata. +type SchemaInfoDef struct { + Title string `yaml:"title,omitempty"` + Author string `yaml:"author,omitempty"` + Contact *SchemaContactDef `yaml:"contact,omitempty"` + Labels map[string]string `yaml:"labels,omitempty"` +} + +// SchemaContactDef contains contact information for a schema owner. +type SchemaContactDef struct { + Name string `yaml:"name,omitempty"` + Email string `yaml:"email,omitempty"` + URL string `yaml:"url,omitempty"` } // FieldDef describes a single field in the schema YAML. type FieldDef struct { - Type string `yaml:"type"` - Description string `yaml:"description,omitempty"` - Default string `yaml:"default,omitempty"` - Nullable bool `yaml:"nullable,omitempty"` - Deprecated bool `yaml:"deprecated,omitempty"` - RedirectTo string `yaml:"redirect_to,omitempty"` - Constraints *ConstraintsDef `yaml:"constraints,omitempty"` - Title string `yaml:"title,omitempty"` - Example string `yaml:"example,omitempty"` - // Examples and ExternalDocs are accepted for forward-compatibility but are - // not used by the offline validator. - Examples any `yaml:"examples,omitempty"` - ExternalDocs any `yaml:"externalDocs,omitempty"` - Tags []string `yaml:"tags,omitempty"` + Type string `yaml:"type"` + Description string `yaml:"description,omitempty"` + Default string `yaml:"default,omitempty"` + Nullable bool `yaml:"nullable,omitempty"` + Deprecated bool `yaml:"deprecated,omitempty"` + RedirectTo string `yaml:"redirect_to,omitempty"` + Constraints *ConstraintsDef `yaml:"constraints,omitempty"` + Title string `yaml:"title,omitempty"` + Example string `yaml:"example,omitempty"` + Examples map[string]ExampleDef `yaml:"examples,omitempty"` + ExternalDocs *ExternalDocsDef `yaml:"externalDocs,omitempty"` + Tags []string `yaml:"tags,omitempty"` // Format is accepted for forward-compatibility but is not used by the // offline validator. Format string `yaml:"format,omitempty"` @@ -62,6 +73,18 @@ type FieldDef struct { Sensitive bool `yaml:"sensitive,omitempty"` } +// ExampleDef represents a named example value. +type ExampleDef struct { + Value string `yaml:"value"` + Summary string `yaml:"summary,omitempty"` +} + +// ExternalDocsDef links to external documentation. +type ExternalDocsDef struct { + Description string `yaml:"description,omitempty"` + URL string `yaml:"url"` +} + // ConstraintsDef uses OAS-style naming for field constraints. type ConstraintsDef struct { Minimum *float64 `yaml:"minimum,omitempty"` @@ -167,6 +190,14 @@ func ParseSchema(data []byte) (*SchemaFile, error) { if !isValidType(f.Type) { return nil, fmt.Errorf("field %s: unknown type %q", path, f.Type) } + for name, ex := range f.Examples { + if ex.Value == "" { + return nil, fmt.Errorf("field %s: example %q: value is required", path, name) + } + } + if f.ExternalDocs != nil && f.ExternalDocs.URL == "" { + return nil, fmt.Errorf("field %s: externalDocs: url is required", path) + } if f.Default != "" { dv, err := parseDefaultAsTyped(f.Type, f.Default) if err != nil { diff --git a/sdk/tools/validate/validate_test.go b/sdk/tools/validate/validate_test.go index 1e98b13f..2f99d882 100644 --- a/sdk/tools/validate/validate_test.go +++ b/sdk/tools/validate/validate_test.go @@ -849,6 +849,132 @@ fields: } } +func TestParseSchema_Metadata(t *testing.T) { + data := `spec_version: "v1" +name: payments +version: 3 +version_description: Added refund_window field +info: + title: Payments + author: platform-team + contact: + name: Pat + email: pat@example.com + url: https://wiki.example.com/team + labels: + team: platform +fields: + payments.fee: + type: number + examples: + low: + value: "0.01" + summary: Low rate + high: + value: "0.99" + externalDocs: + description: Fee guide + url: https://docs.example.com/fees + payments.webhook: + type: url + constraints: + allowed_schemes: [https, sftp] +` + doc, err := ParseSchema([]byte(data)) + if err != nil { + t.Fatal(err) + } + if doc.VersionDescription != "Added refund_window field" { + t.Errorf("unexpected version_description: %q", doc.VersionDescription) + } + info := doc.Info + if info == nil { + t.Fatal("expected non-nil Info") + } + if info.Title != "Payments" || info.Author != "platform-team" { + t.Errorf("unexpected info: %+v", info) + } + if info.Contact == nil || info.Contact.Name != "Pat" || info.Contact.Email != "pat@example.com" || info.Contact.URL != "https://wiki.example.com/team" { + t.Errorf("unexpected contact: %+v", info.Contact) + } + if info.Labels["team"] != "platform" { + t.Errorf("unexpected labels: %v", info.Labels) + } + + fee := doc.Fields["payments.fee"] + if len(fee.Examples) != 2 { + t.Fatalf("expected 2 examples, got %d", len(fee.Examples)) + } + if ex := fee.Examples["low"]; ex.Value != "0.01" || ex.Summary != "Low rate" { + t.Errorf("unexpected example: %+v", ex) + } + if ex := fee.Examples["high"]; ex.Value != "0.99" || ex.Summary != "" { + t.Errorf("unexpected example: %+v", ex) + } + if fee.ExternalDocs == nil || fee.ExternalDocs.URL != "https://docs.example.com/fees" || fee.ExternalDocs.Description != "Fee guide" { + t.Errorf("unexpected externalDocs: %+v", fee.ExternalDocs) + } + + webhook := doc.Fields["payments.webhook"] + if webhook.Constraints == nil || len(webhook.Constraints.AllowedSchemes) != 2 { + t.Fatalf("unexpected constraints: %+v", webhook.Constraints) + } +} + +func TestParseSchema_MetadataErrors(t *testing.T) { + tests := []struct { + name string + data string + msgSubstr string + }{ + {"info is not a mapping", `spec_version: "v1" +name: test +info: just-a-string +fields: + a: + type: string`, "invalid YAML"}, + {"examples is not a mapping", `spec_version: "v1" +name: test +fields: + a: + type: string + examples: [one, two]`, "invalid YAML"}, + {"example without value", `spec_version: "v1" +name: test +fields: + a: + type: string + examples: + low: + summary: missing value`, `example "low": value is required`}, + {"externalDocs is not a mapping", `spec_version: "v1" +name: test +fields: + a: + type: string + externalDocs: https://docs.example.com`, "invalid YAML"}, + {"externalDocs without url", `spec_version: "v1" +name: test +fields: + a: + type: string + externalDocs: + description: missing url`, "externalDocs: url is required"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseSchema([]byte(tt.data)) + if err == nil { + t.Fatal("expected error") + } + if !containsStr(err.Error(), tt.msgSubstr) { + t.Errorf("expected error containing %q, got: %v", tt.msgSubstr, err) + } + }) + } +} + func TestParseConfig_Errors(t *testing.T) { tests := []struct { name string