diff --git a/docs/README.md b/docs/README.md index 6bb03dea0..0bc1cf13b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -98,16 +98,14 @@ agnostic documentation in the form of a JSON document structured as follows: ## Enabling the Plugin -To enable the plugin simply import both the `docs` package as follows: +To enable the plugin, import the docs DSL package in your design. Importing the DSL also registers the plugin automatically: ```go import ( - _ "goa.design/plugins/v3/docs" + . "goa.design/plugins/v3/docs/dsl" . "goa.design/goa/v3/dsl" ) ``` -Note the use of blank identifier to import the `docs` package which is necessary -as the package is imported solely for its side-effects (initialization). ## Effects on Code Generation @@ -121,3 +119,34 @@ If `goa gen` is invoked with a custom output path (i.e. with the `-o` argument) then the plugin appends to any pre-existing `doc.json` file instead of overwriting. Make sure to delete the file prior to running `goa gen` when using the `-o` option. + +### Using JSON tags as field names + +By default, the generated documentation uses attribute names as field names in definitions and examples. To instead use JSON struct tags declared via the `Meta("struct:tag:json", ...)` DSL, enable it in your design using the docs DSL (which also registers the plugin): + +```go +import ( + . "goa.design/plugins/v3/docs/dsl" + . "goa.design/goa/v3/dsl" +) + +func init() { + UseJSONTags() +} +``` + +When enabled, object property names in `definitions`, payloads, results, and error schemas/examples are renamed to match their JSON tag (the part before the first comma, e.g. `name,omitempty` becomes `name`). Fields tagged with `"-"` are ignored for renaming and keep their original names. The transformation does not mutate the Goa OpenAPI global definitions. + +### Inlining $refs in JSON Schema + +To inline `$ref` schemas where possible (while preserving cycles), enable it via the docs DSL: + +```go +import ( + . "goa.design/plugins/v3/docs/dsl" +) + +func init() { + InlineRefs() +} +``` diff --git a/docs/dsl/docs.go b/docs/dsl/docs.go new file mode 100644 index 000000000..430778bb0 --- /dev/null +++ b/docs/dsl/docs.go @@ -0,0 +1,18 @@ +package dsl + +import ( + "goa.design/plugins/v3/docs" + "goa.design/plugins/v3/docs/expr" +) + +// init registers the docs plugin when importing the DSL. +func init() { docs.Register() } + +// UseJSONTags configures the docs plugin to use JSON struct tags declared via +// Meta("struct:tag:json", ...) as field names in generated docs. This setting +// affects definitions, payloads, results, and error schemas and examples. +func UseJSONTags() { expr.Root.UseJSONTags = true } + +// InlineRefs configures the docs plugin to inline referenced schemas in JSON +// Schema output where possible. Cycles are preserved by keeping $ref. +func InlineRefs() { expr.Root.InlineRefs = true } diff --git a/docs/expr/root.go b/docs/expr/root.go new file mode 100644 index 000000000..d7d07c004 --- /dev/null +++ b/docs/expr/root.go @@ -0,0 +1,41 @@ +package expr + +import ( + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// Root is the design root expression for the docs plugin. +var Root = &RootExpr{} + +type ( + // RootExpr keeps track of docs plugin configuration toggles. + RootExpr struct { + // UseJSONTags instructs the docs generator to use JSON struct tags + // specified via Meta ("struct:tag:json") as field names. + UseJSONTags bool + // InlineRefs instructs the docs generator to inline $ref schemas by + // replacing them with copies of their referenced definitions where + // possible. Cycles are preserved by leaving $ref in place when needed. + InlineRefs bool + } +) + +// Register design root with eval engine. +func init() { + _ = eval.Register(Root) +} + +// EvalName returns the name used in error messages. +func (r *RootExpr) EvalName() string { return "Docs plugin" } + +// WalkSets implements eval.Root. No-op; configuration is global. +func (*RootExpr) WalkSets(eval.SetWalker) {} + +// DependsOn tells the eval engine to run the goa DSL first. +func (*RootExpr) DependsOn() []eval.Root { return []eval.Root{expr.Root} } + +// Packages returns the import path to the Go packages that make up the DSL. +// This is used to skip frames that point to files in these packages when +// computing the location of errors. +func (*RootExpr) Packages() []string { return []string{"goa.design/plugins/v3/docs/dsl"} } diff --git a/docs/generate.go b/docs/generate.go index fe5b28845..37c9aa681 100644 --- a/docs/generate.go +++ b/docs/generate.go @@ -5,19 +5,29 @@ import ( "fmt" "os" "path/filepath" + "strings" + "sync" "text/template" "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/openapi" + plugexpr "goa.design/plugins/v3/docs/expr" ) -// init registers the plugin generator function. -func init() { - codegen.RegisterPlugin("docs", "gen", nil, Generate) +// Register registers the docs plugin generator exactly once. +var registerOnce sync.Once + +func Register() { + registerOnce.Do(func() { + codegen.RegisterPlugin("docs", "gen", nil, Generate) + }) } +// Backward compatibility: register on package import. +func init() { Register() } + // Generate produces the documentation JSON file. func Generate(_ string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { for _, root := range roots { @@ -29,11 +39,87 @@ func Generate(_ string, roots []eval.Root, files []*codegen.File) ([]*codegen.Fi } func docsFile(r *expr.RootExpr) *codegen.File { + docs := &data{ - API: apiDocs(r.API), - Services: servicesDocs(r), - Definitions: openapi.Definitions, + API: apiDocs(r.API), + Services: servicesDocs(r), } + + // Default behavior: use global OpenAPI definitions to preserve ordering and + // compatibility with existing golden tests. + defs := openapi.Definitions + + // If either option is enabled, build a local definition map for this root + // and apply transforms/inlining as needed, isolating from global state. + if plugexpr.Root.UseJSONTags || plugexpr.Root.InlineRefs { + local := make(map[string]*openapi.Schema) + prev := openapi.Definitions + openapi.Definitions = make(map[string]*openapi.Schema) + for _, tpe := range r.Types { + if ut, ok := tpe.(*expr.UserTypeExpr); ok { + openapi.GenerateTypeDefinition(r.API, ut) + } + } + for _, rt := range r.ResultTypes { + openapi.GenerateResultTypeDefinition(r.API, rt, expr.DefaultView) + } + for n, s := range openapi.Definitions { + local[n] = dupSchema(s) + } + openapi.Definitions = prev + + // Apply JSON tag transforms if requested + if plugexpr.Root.UseJSONTags { + local = transformDefinitionsWithJSONTagsHybrid(r, local, nil) + } + defs = local + } else { + // When not transforming, avoid leaking the synthetic Empty type produced by + // Goa internals in some scenarios by filtering it out, but only when present. + if _, hasEmpty := defs["Empty"]; hasEmpty { + filtered := make(map[string]*openapi.Schema, len(defs)) + for n, s := range defs { + if n == "Empty" { + continue + } + filtered[n] = s + } + defs = filtered + } + } + + docs.Definitions = defs + + // Inline $refs if requested via DSL flag. + if plugexpr.Root.InlineRefs { + // Inline inside service payloads/results/errors. + for _, svc := range docs.Services { + for _, m := range svc.Methods { + if m.Payload != nil && m.Payload.Type != nil { + inlineRefsInSchema(m.Payload.Type, defs, make(map[string]bool)) + } + if m.StreamingPayload != nil && m.StreamingPayload.Type != nil { + inlineRefsInSchema(m.StreamingPayload.Type, defs, make(map[string]bool)) + } + if m.Result != nil && m.Result.Type != nil { + inlineRefsInSchema(m.Result.Type, defs, make(map[string]bool)) + } + if m.StreamingResult != nil && m.StreamingResult.Type != nil { + inlineRefsInSchema(m.StreamingResult.Type, defs, make(map[string]bool)) + } + for _, e := range m.Errors { + if e != nil && e.Type != nil { + inlineRefsInSchema(e.Type, defs, make(map[string]bool)) + } + } + } + } + // Inline inside definitions themselves (properties that refer to other defs). + for _, def := range defs { + inlineRefsInSchema(def, defs, make(map[string]bool)) + } + } + jsonPath := filepath.Join(codegen.Gendir, "docs.json") if _, err := os.Stat(jsonPath); !os.IsNotExist(err) { // Goa does not delete files in the top-level gen folder. @@ -56,6 +142,58 @@ func docsFile(r *expr.RootExpr) *codegen.File { } } +// dupSchema creates a safe deep copy of the given schema, ensuring maps are initialized. +func dupSchema(s *openapi.Schema) *openapi.Schema { + if s == nil { + return nil + } + js := openapi.Schema{ + ID: s.ID, + Description: s.Description, + Schema: s.Schema, + Type: s.Type, + DefaultValue: s.DefaultValue, + Title: s.Title, + Media: s.Media, + ReadOnly: s.ReadOnly, + PathStart: s.PathStart, + Links: s.Links, + Ref: s.Ref, + Enum: s.Enum, + Format: s.Format, + Pattern: s.Pattern, + Minimum: s.Minimum, + Maximum: s.Maximum, + MinLength: s.MinLength, + MaxLength: s.MaxLength, + MinItems: s.MinItems, + MaxItems: s.MaxItems, + Required: s.Required, + AdditionalProperties: s.AdditionalProperties, + Properties: make(map[string]*openapi.Schema, len(s.Properties)), + Definitions: make(map[string]*openapi.Schema, len(s.Definitions)), + AnyOf: nil, + Example: s.Example, + Extensions: s.Extensions, + } + for n, p := range s.Properties { + js.Properties[n] = dupSchema(p) + } + if s.Items != nil { + js.Items = dupSchema(s.Items) + } + for n, d := range s.Definitions { + js.Definitions[n] = dupSchema(d) + } + if len(s.AnyOf) > 0 { + js.AnyOf = make([]*openapi.Schema, len(s.AnyOf)) + for i := range s.AnyOf { + js.AnyOf[i] = dupSchema(s.AnyOf[i]) + } + } + return &js +} + func apiDocs(api *expr.APIExpr) *apiData { data := &apiData{ Name: api.Name, @@ -209,20 +347,32 @@ func generatePayload(api *expr.APIExpr, att *expr.AttributeExpr, nameScope *code } schema := openapi.AttributeTypeSchema(api, att) + ex := att.Example(api.ExampleGenerator) + if plugexpr.Root.UseJSONTags { + // avoid mutating shared schema nodes + schema = dupSchema(schema) + applyJSONTagsToSchema(att, schema) + ex = transformExampleWithJSONTags(att, ex) + } return &payloadData{ Type: schema, - Example: att.Example(api.ExampleGenerator), + Example: ex, } } func generateError(api *expr.APIExpr, er *expr.ErrorExpr) *errorData { - _, temporary := er.AttributeExpr.Meta["goa:error:temporary"] - _, timeout := er.AttributeExpr.Meta["goa:error:timeout"] - _, fault := er.AttributeExpr.Meta["goa:error:fault"] + _, temporary := er.Meta["goa:error:temporary"] + _, timeout := er.Meta["goa:error:timeout"] + _, fault := er.Meta["goa:error:fault"] + sch := openapi.AttributeTypeSchema(api, er.AttributeExpr) + if plugexpr.Root.UseJSONTags { + sch = dupSchema(sch) + applyJSONTagsToSchema(er.AttributeExpr, sch) + } return &errorData{ Name: er.Name, Description: er.Description, - Type: openapi.AttributeTypeSchema(api, er.AttributeExpr), + Type: sch, Temporary: temporary, Timeout: timeout, Fault: fault, @@ -236,3 +386,265 @@ func toJSON(d interface{}) string { } return string(b) } + +// inlineRefsInSchema replaces $ref with a deep copy of the referenced +// definition where possible. It recurses through properties/items/anyOf. +// A small visited set prevents infinite recursion on cycles. +func inlineRefsInSchema(s *openapi.Schema, defs map[string]*openapi.Schema, visiting map[string]bool) { + if s == nil { + return + } + // Inline reference if it points to local definitions. + if after, ok := strings.CutPrefix(s.Ref, "#/definitions/"); ok { + name := after + if !visiting[name] { + if def, ok := defs[name]; ok { + visiting[name] = true + dup := dupSchema(def) + // Recurse into the dup first to inline nested refs. + inlineRefsInSchema(dup, defs, visiting) + // Replace s with contents of dup (shallow copy fields and maps) + *s = *dup + visiting[name] = false + } + } + } + // Recurse + if s.Items != nil { + inlineRefsInSchema(s.Items, defs, visiting) + } + if s.Properties != nil { + for _, p := range s.Properties { + inlineRefsInSchema(p, defs, visiting) + } + } + if s.AdditionalProperties != nil { + if asp, ok := s.AdditionalProperties.(*openapi.Schema); ok { + inlineRefsInSchema(asp, defs, visiting) + } + } + if s.AnyOf != nil { + for _, a := range s.AnyOf { + inlineRefsInSchema(a, defs, visiting) + } + } +} + +// transformDefinitionsWithJSONTagsHybrid tries attrIndex first; if not found, falls back to Root.UserType lookup. +func transformDefinitionsWithJSONTagsHybrid(r *expr.RootExpr, defs map[string]*openapi.Schema, attrIndex map[string]*expr.AttributeExpr) map[string]*openapi.Schema { + if len(defs) == 0 { + return defs + } + out := make(map[string]*openapi.Schema, len(defs)) + for name, sch := range defs { + dup := dupSchema(sch) + if att, ok := attrIndex[name]; ok && att != nil { + applyJSONTagsToSchema(att, dup) + } else if ut := r.UserType(name); ut != nil { + applyJSONTagsToSchema(ut.Attribute(), dup) + } + out[name] = dup + } + return out +} + +// applyJSONTagsToSchema mutates s to use JSON tag names from Meta on the given +// attribute and its descendants. It preserves required field semantics and +// updates examples when present. +func applyJSONTagsToSchema(att *expr.AttributeExpr, s *openapi.Schema) { + if att == nil || s == nil { + return + } + // If this schema is a ref, we expect the referenced definition to be transformed separately. + if s.Ref != "" { + return + } + + // Unwrap user/result types to their underlying attributes before processing. + for { + switch t := att.Type.(type) { + case *expr.ResultTypeExpr: + att = t.AttributeExpr + continue + case expr.UserType: + att = t.Attribute() + continue + } + break + } + + // Recurse into composite types first so nested structures are handled. + switch t := att.Type.(type) { + case *expr.Array: + if s.Items != nil { + applyJSONTagsToSchema(t.ElemType, s.Items) + } + case *expr.Map: + if as, ok := s.AdditionalProperties.(*openapi.Schema); ok { + applyJSONTagsToSchema(t.ElemType, as) + } + case *expr.Union: + for i, v := range t.Values { + if i < len(s.AnyOf) { + applyJSONTagsToSchema(v.Attribute, s.AnyOf[i]) + } + } + } + + // Handle object property renaming and example/required updates. + if obj := expr.AsObject(att.Type); obj != nil && s.Properties != nil { + // Build new properties map using JSON tag names. Use walkAttribute so bases/references are included. + newProps := make(map[string]*openapi.Schema, len(s.Properties)) + nameMap := make(map[string]string, len(*obj)) + _ = walkAttribute(att, func(oldName string, child *expr.AttributeExpr) error { + jsonName := jsonTagName(child) + if jsonName == "" || jsonName == "-" { + jsonName = oldName + } + // Find property schema by old key (fallback to jsonName if already renamed). + prop := s.Properties[oldName] + if prop == nil { + prop = s.Properties[jsonName] + } + if prop != nil { + applyJSONTagsToSchema(child, prop) + if _, exists := newProps[jsonName]; !exists { + newProps[jsonName] = prop + } + } + nameMap[oldName] = jsonName + return nil + }) + s.Properties = newProps + + if len(s.Required) > 0 { + newReq := make([]string, 0, len(s.Required)) + for _, rn := range s.Required { + if jn, ok := nameMap[rn]; ok { + if _, exists := newProps[jn]; exists { + newReq = append(newReq, jn) + } + } else if _, exists := newProps[rn]; exists { + newReq = append(newReq, rn) + } + } + s.Required = newReq + } + + if s.Example != nil { + s.Example = transformExampleWithJSONTags(att, s.Example) + } + } +} + +// walkAttribute iterates over the given attribute, its bases and references (if any), +// calling the iterator for each field of object types. This mirrors goa's internal +// expr.walkAttribute to ensure bases and references are considered when transforming. +func walkAttribute(att *expr.AttributeExpr, it func(name string, a *expr.AttributeExpr) error) error { //nolint:cyclop + switch dt := att.Type.(type) { + case expr.UserType: + if err := walkAttribute(dt.Attribute(), it); err != nil { + return err + } + case *expr.Object: + for _, nat := range *dt { + if err := it(nat.Name, nat.Attribute); err != nil { + return err + } + } + } + for _, b := range att.Bases { + if err := walkAttribute(&expr.AttributeExpr{Type: b}, it); err != nil { + return err + } + } + for _, r := range att.References { + if err := walkAttribute(&expr.AttributeExpr{Type: r}, it); err != nil { + return err + } + } + return nil +} + +// jsonTagName extracts the JSON tag field name from the attribute Meta if set. +// It supports values like "name,omitempty" and returns "name". +func jsonTagName(att *expr.AttributeExpr) string { + if att == nil || att.Meta == nil { + return "" + } + if vals, ok := att.Meta["struct:tag:json"]; ok && len(vals) > 0 { + tag := vals[len(vals)-1] + if idx := strings.Index(tag, ","); idx >= 0 { + tag = tag[:idx] + } + return tag + } + return "" +} + +// transformExampleWithJSONTags rewrites example map keys to match JSON tags from +// Meta. It recurses through objects and arrays. For user and result types it +// recurses into the underlying attribute. +func transformExampleWithJSONTags(att *expr.AttributeExpr, ex any) any { + if att == nil || ex == nil { + return ex + } + switch t := att.Type.(type) { + case *expr.ResultTypeExpr: + return transformExampleWithJSONTags(t.AttributeExpr, ex) + case expr.UserType: + return transformExampleWithJSONTags(t.Attribute(), ex) + case *expr.Object: + m, ok := ex.(map[string]any) + if !ok { + return ex + } + res := make(map[string]any, len(m)) + for _, nat := range *t { + oldName := nat.Name + jsonName := jsonTagName(nat.Attribute) + if jsonName == "" || jsonName == "-" { + jsonName = oldName + } + if val, ok := m[oldName]; ok { + res[jsonName] = transformExampleWithJSONTags(nat.Attribute, val) + } + } + return res + case *expr.Array: + if arr, ok := ex.([]any); ok { + out := make([]any, len(arr)) + for i := range arr { + out[i] = transformExampleWithJSONTags(t.ElemType, arr[i]) + } + return out + } + return ex + case *expr.Map: + // Only transform element values. + switch m := ex.(type) { + case map[string]any: + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = transformExampleWithJSONTags(t.ElemType, v) + } + return out + case map[any]any: + out := make(map[any]any, len(m)) + for k, v := range m { + out[k] = transformExampleWithJSONTags(t.ElemType, v) + } + return out + default: + return ex + } + case *expr.Union: + // Attempt best-effort transform by applying first variant. + if len(t.Values) > 0 { + return transformExampleWithJSONTags(t.Values[0].Attribute, ex) + } + return ex + default: + return ex + } +} diff --git a/docs/generate_test.go b/docs/generate_test.go index c2fec6171..1efe29be7 100644 --- a/docs/generate_test.go +++ b/docs/generate_test.go @@ -2,6 +2,7 @@ package docs_test import ( "bytes" + "encoding/json" "flag" "fmt" "os" @@ -11,13 +12,35 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + . "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" + openapi "goa.design/goa/v3/http/codegen/openapi" "goa.design/plugins/v3/docs" + . "goa.design/plugins/v3/docs/dsl" + plugexpr "goa.design/plugins/v3/docs/expr" "goa.design/plugins/v3/docs/testdata" ) var update = flag.Bool("update", false, "update golden files") +// genDocs unmarshals the generated docs JSON into a generic map for assertions. +func genDocs(t *testing.T, dsl func()) map[string]any { + t.Helper() + root := codegen.RunDSL(t, dsl) + prev := openapi.Definitions + openapi.Definitions = make(map[string]*openapi.Schema) + fs, err := docs.Generate("", []eval.Root{root}, nil) + openapi.Definitions = prev + require.NoError(t, err) + require.NotEmpty(t, fs) + require.NotEmpty(t, fs[0].SectionTemplates) + var w bytes.Buffer + require.NoError(t, fs[0].SectionTemplates[0].Write(&w)) + var m map[string]any + require.NoError(t, json.Unmarshal(w.Bytes(), &m)) + return m +} + func TestDocs(t *testing.T) { cases := []struct { Name string @@ -54,3 +77,217 @@ func TestDocs(t *testing.T) { }) } } + +func TestUseJSONTags(t *testing.T) { + t.Cleanup(func() { plugexpr.Root.UseJSONTags = false; plugexpr.Root.InlineRefs = false }) + + docsMap := genDocs(t, func() { + UseJSONTags() + API("Test", func() {}) + var Address = Type("Address", func() { + Field(1, "street", String, func() { Meta("struct:tag:json", "street_name,omitempty") }) + Field(2, "zip", Int) + Required("street") + }) + var User = Type("User", func() { + Field(1, "name", String, func() { Meta("struct:tag:json", "full_name") }) + Field(2, "address", Address, func() { Meta("struct:tag:json", "addr") }) + Field(3, "skip", String, func() { Meta("struct:tag:json", "-") }) + Required("name", "address") + }) + Service("S", func() { Method("M1", func() { Payload(User); HTTP(func() { GET("/") }); GRPC(func() {}) }) }) + }) + + methods := docsMap["services"].(map[string]any)["S"].(map[string]any)["methods"].(map[string]any) + m1 := methods["M1"].(map[string]any) + payload := m1["payload"].(map[string]any) + ex := payload["example"].(map[string]any) + if _, ok := ex["full_name"]; !ok { + t.Fatalf("expected example to include key 'full_name', got keys: %#v", ex) + } + if _, ok := ex["addr"]; !ok { + t.Fatalf("expected example to include key 'addr', got keys: %#v", ex) + } + + defs := docsMap["definitions"].(map[string]any) + userDef := defs["User"].(map[string]any) + props := userDef["properties"].(map[string]any) + if _, ok := props["full_name"]; !ok { + t.Fatalf("expected 'full_name' property in User definition, got: %#v", props) + } + if _, ok := props["addr"]; !ok { + t.Fatalf("expected 'addr' property in User definition, got: %#v", props) + } + if _, ok := props["skip"]; !ok { + t.Fatalf("expected 'skip' property to remain unchanged when tag is '-', got: %#v", props) + } + req := userDef["required"].([]any) + assert.Contains(t, req, any("full_name")) + assert.Contains(t, req, any("addr")) + + addrDef := defs["Address"].(map[string]any) + addrReq := addrDef["required"].([]any) + assert.Contains(t, addrReq, any("street_name")) +} + +func TestInlineRefs(t *testing.T) { + t.Cleanup(func() { plugexpr.Root.UseJSONTags = false; plugexpr.Root.InlineRefs = false }) + + docsMap := genDocs(t, func() { + InlineRefs() + API("Test", func() {}) + var Inner = Type("Inner", func() { Field(1, "value", String) }) + var Outer = Type("Outer", func() { Field(1, "inner", Inner) }) + var MapHolder = Type("MapHolder", func() { Field(1, "m", MapOf(String, Inner)) }) + Service("S", func() { + Method("M1", func() { Payload(Outer); HTTP(func() { GET("/") }); GRPC(func() {}) }) + Method("M2", func() { Payload(ArrayOf(Inner)); HTTP(func() { GET("/array") }); GRPC(func() {}) }) + Method("M3", func() { Payload(MapHolder); HTTP(func() { GET("/map") }); GRPC(func() {}) }) + }) + }) + + svc := docsMap["services"].(map[string]any)["S"].(map[string]any) + methods := svc["methods"].(map[string]any) + m1Type := methods["M1"].(map[string]any)["payload"].(map[string]any)["type"].(map[string]any) + if _, hasRef := m1Type["$ref"]; hasRef { + t.Fatalf("expected inlined schema for M1 payload, found $ref: %#v", m1Type) + } + if _, hasProps := m1Type["properties"]; !hasProps { + t.Fatalf("expected properties in inlined schema for M1 payload, got: %#v", m1Type) + } + m2Type := methods["M2"].(map[string]any)["payload"].(map[string]any)["type"].(map[string]any) + items := m2Type["items"].(map[string]any) + if _, hasRef := items["$ref"]; hasRef { + t.Fatalf("expected inlined schema for M2 payload items, found $ref: %#v", items) + } + if _, hasProps := items["properties"]; !hasProps { + t.Fatalf("expected properties in inlined schema for M2 payload items, got: %#v", items) + } + defs := docsMap["definitions"].(map[string]any) + innerProp := defs["Outer"].(map[string]any)["properties"].(map[string]any)["inner"].(map[string]any) + if _, hasRef := innerProp["$ref"]; hasRef { + t.Fatalf("expected inlined nested property in definitions, found $ref: %#v", innerProp) + } + mprop := defs["MapHolder"].(map[string]any)["properties"].(map[string]any)["m"].(map[string]any) + addl := mprop["additionalProperties"].(map[string]any) + if _, hasRef := addl["$ref"]; hasRef { + t.Fatalf("expected inlined schema in map additionalProperties, found $ref: %#v", addl) + } +} + +func TestBothOptions(t *testing.T) { + t.Cleanup(func() { plugexpr.Root.UseJSONTags = false; plugexpr.Root.InlineRefs = false }) + docsMap := genDocs(t, func() { + UseJSONTags() + InlineRefs() + API("Test", func() {}) + var X = Type("X", func() { + Field(1, "A", String, func() { Meta("struct:tag:json", "aa") }) + Field(2, "b", Int) + Required("A") + }) + Service("S", func() { Method("M1", func() { Payload(X); HTTP(func() { GET("/") }); GRPC(func() {}) }) }) + }) + pt := docsMap["services"].(map[string]any)["S"].(map[string]any)["methods"].(map[string]any)["M1"].(map[string]any)["payload"].(map[string]any)["type"].(map[string]any) + if _, hasRef := pt["$ref"]; hasRef { + t.Fatalf("expected inlined schema for payload, found $ref: %#v", pt) + } + props := pt["properties"].(map[string]any) + if _, ok := props["aa"]; !ok { + t.Fatalf("expected JSON tag property 'aa' in inlined payload schema, got: %#v", props) + } + if req, ok := pt["required"].([]any); ok { + assert.Equal(t, 1, len(req)) + assert.Contains(t, req, any("aa")) + } else { + t.Fatalf("expected required array in inlined payload schema, got: %#v", pt) + } + defs := docsMap["definitions"].(map[string]any) + if xdef, ok := defs["X"].(map[string]any); ok { + if xreq, ok := xdef["required"].([]any); ok { + assert.Contains(t, xreq, any("aa")) + } + } +} + +func TestJSONTagsAndInlineRefs_Complex(t *testing.T) { + t.Cleanup(func() { plugexpr.Root.UseJSONTags = false; plugexpr.Root.InlineRefs = false }) + docsMap := genDocs(t, func() { + UseJSONTags() + InlineRefs() + API("Test", func() {}) + var TimeWindow = Type("TimeWindow", func() { + Field(1, "Start", String, func() { Meta("struct:tag:json", "start") }) + Field(2, "End", String, func() { Meta("struct:tag:json", "end") }) + Required("Start", "End") + }) + var SeriesSource = Type("SeriesSource", func() { + Field(1, "DeviceAlias", String, func() { Meta("struct:tag:json", "device_alias") }) + Field(2, "SignalAlias", String, func() { Meta("struct:tag:json", "signal_alias") }) + Required("DeviceAlias", "SignalAlias") + }) + var TimeSeriesInput = Type("TimeSeriesInput", func() { + Field(1, "SessionID", String, func() { Meta("struct:tag:json", "session_id") }) + Field(2, "Sources", ArrayOf(SeriesSource), func() { Meta("struct:tag:json", "sources") }) + Field(3, "Window", TimeWindow, func() { Meta("struct:tag:json", "window") }) + Required("SessionID", "Sources", "Window") + }) + var TSResult = Type("TSResult", func() { + Field(1, "Alarms", ArrayOf(String), func() { Meta("struct:tag:json", "alarms") }) + Field(2, "EvidenceRefs", ArrayOf(String), func() { Meta("struct:tag:json", "evidence_refs") }) + Required("Alarms", "EvidenceRefs") + }) + Service("S", func() { + Method("Get", func() { Payload(TimeSeriesInput); Result(TSResult); HTTP(func() { GET("/") }); GRPC(func() {}) }) + }) + }) + defs := docsMap["definitions"].(map[string]any) + tsi := defs["TimeSeriesInput"].(map[string]any) + props := tsi["properties"].(map[string]any) + _, hasSID := props["session_id"] + _, hasSrc := props["sources"] + _, hasWin := props["window"] + assert.True(t, hasSID) + assert.True(t, hasSrc) + assert.True(t, hasWin) + req := tsi["required"].([]any) + assert.Contains(t, req, any("session_id")) + assert.Contains(t, req, any("sources")) + assert.Contains(t, req, any("window")) + svc := docsMap["services"].(map[string]any)["S"].(map[string]any) + methods := svc["methods"].(map[string]any) + get := methods["Get"].(map[string]any) + pt := get["payload"].(map[string]any)["type"].(map[string]any) + if _, hasRef := pt["$ref"]; hasRef { + t.Fatalf("expected inlined payload schema, found $ref: %#v", pt) + } + ppt := pt["properties"].(map[string]any) + _, ok1 := ppt["session_id"] + _, ok2 := ppt["sources"] + _, ok3 := ppt["window"] + assert.True(t, ok1) + assert.True(t, ok2) + assert.True(t, ok3) + if reqP, ok := pt["required"].([]any); ok { + assert.Contains(t, reqP, any("session_id")) + assert.Contains(t, reqP, any("sources")) + assert.Contains(t, reqP, any("window")) + } else { + t.Fatalf("expected required array in payload type, got: %#v", pt) + } + rt := get["result"].(map[string]any)["type"].(map[string]any) + if _, hasRef := rt["$ref"]; hasRef { + t.Fatalf("expected inlined result schema, found $ref: %#v", rt) + } + rprops := rt["properties"].(map[string]any) + _, okA := rprops["alarms"] + _, okE := rprops["evidence_refs"] + assert.True(t, okA) + assert.True(t, okE) + if reqR, ok := rt["required"].([]any); ok { + assert.Contains(t, reqR, any("alarms")) + assert.Contains(t, reqR, any("evidence_refs")) + } else { + t.Fatalf("expected required array in result type, got: %#v", rt) + } +}