From 005d7702bc9f85acbf752c24fa895799f3b3a207 Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Fri, 21 Feb 2025 20:53:37 -0300 Subject: [PATCH] Feature: Add support for generics Allow generating documentation for generic types. --- docparse/docparse.go | 2 +- docparse/docparse_test.go | 2 +- docparse/jsonschema.go | 125 +++++++++++++++++++++-- docparse/jsonschema_test.go | 4 +- testdata/openapi2/src/generics/in.go | 26 +++++ testdata/openapi2/src/generics/want.yaml | 56 ++++++++++ 6 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 testdata/openapi2/src/generics/in.go create mode 100644 testdata/openapi2/src/generics/want.yaml diff --git a/docparse/docparse.go b/docparse/docparse.go index ee8957e..3dd847b 100644 --- a/docparse/docparse.go +++ b/docparse/docparse.go @@ -177,7 +177,7 @@ var ( ) // parseComment a single comment block in the file filePath. -func parseComment(prog *Program, comment, pkgPath, filePath string) ([]*Endpoint, int, error) { +func parseComment(prog *Program, comment, _, filePath string) ([]*Endpoint, int, error) { e := &Endpoint{} // Get start line and determine if this is a comment block. diff --git a/docparse/docparse_test.go b/docparse/docparse_test.go index 84f53e0..5eb4b61 100644 --- a/docparse/docparse_test.go +++ b/docparse/docparse_test.go @@ -585,7 +585,7 @@ func TestParseResponse(t *testing.T) { t.Errorf("wrong code\nwant: %v\ngot: %v", tt.wantCode, code) } if d := diff.Diff(tt.wantResp, resp); d != "" { - t.Errorf(d) + t.Error(d) } }) } diff --git a/docparse/jsonschema.go b/docparse/jsonschema.go index ba0a10a..9d507f7 100644 --- a/docparse/jsonschema.go +++ b/docparse/jsonschema.go @@ -70,7 +70,7 @@ func structToSchema(prog *Program, name, tagName string, ref Reference) (*Schema name = p.Name } - prop, err := fieldToSchema(prog, name, tagName, ref, p.KindField) + prop, err := fieldToSchema(prog, name, tagName, ref, p.KindField, nil) if err != nil { return nil, fmt.Errorf("cannot parse %v: %v", ref.Lookup, err) } @@ -215,7 +215,13 @@ func setTags(name, fName string, p *Schema, tags []string) error { } // Convert a struct field to JSON schema. -func fieldToSchema(prog *Program, fName, tagName string, ref Reference, f *ast.Field) (*Schema, error) { +func fieldToSchema( + prog *Program, + fName, tagName string, + ref Reference, + f *ast.Field, + generics map[string]string, +) (*Schema, error) { var p Schema if f.Doc != nil { @@ -283,6 +289,10 @@ start: if mappedType != "" { p.Type = JSONSchemaType(mappedType) } + if generics != nil && generics[typ.Name] != "" { + mappedType = "generics" + p.Type = JSONSchemaType(generics[typ.Name]) + } if p.Type == "enum" && len(p.Enum) == 0 { if variations, err := getEnumVariations(ref.File, pkg, typ.Name); len(variations) > 0 { p.Enum = variations @@ -320,7 +330,7 @@ start: p.Properties = map[string]*Schema{} for _, f := range typ.Fields.List { propName := goutil.TagName(f, tagName) - prop, err := fieldToSchema(prog, propName, tagName, ref, f) + prop, err := fieldToSchema(prog, propName, tagName, ref, f, generics) if err != nil { return nil, fmt.Errorf("anon struct: %v", err) } @@ -373,7 +383,7 @@ start: case *ast.ArrayType: isEnum := p.Type == "enum" p.Type = "array" - err := resolveArray(prog, ref, pkg, &p, resolvType.Elt, isEnum) + err := resolveArray(prog, ref, pkg, &p, resolvType.Elt, isEnum, generics) if err != nil { return nil, err } @@ -393,6 +403,9 @@ start: dbg("ERR FOUND MapType: %s", err.Error()) return &p, nil } + if generics != nil && generics[vtyp.Name] != "" { + vtyp.Name = generics[vtyp.Name] + } if isPrimitive(vtyp.Name) { // we are done, no need for a lookup of a custom type p.AdditionalProperties = &Schema{Type: JSONSchemaType(vtyp.Name)} @@ -418,13 +431,34 @@ start: isEnum := p.Type == "enum" p.Type = "array" - err := resolveArray(prog, ref, pkg, &p, typ.Elt, isEnum) + err := resolveArray(prog, ref, pkg, &p, typ.Elt, isEnum, generics) if err != nil { return nil, err } return &p, nil + // Generic types + case *ast.IndexExpr: + genericsIdent, ok := typ.X.(*ast.Ident) + if !ok { + return nil, fmt.Errorf("unknown generic type: %T", typ.X) + } + if err := fillGenericsSchema(prog, &p, tagName, ref, genericsIdent, generics, typ.Index); err != nil { + return nil, fmt.Errorf("generic fieldToSchema: %v", err) + } + return &p, nil + + case *ast.IndexListExpr: + genericsIdent, ok := typ.X.(*ast.Ident) + if !ok { + return nil, fmt.Errorf("unknown generic type: %T", typ.X) + } + if err := fillGenericsSchema(prog, &p, tagName, ref, genericsIdent, generics, typ.Indices...); err != nil { + return nil, fmt.Errorf("generic fieldToSchema: %v", err) + } + return &p, nil + default: return nil, fmt.Errorf("fieldToSchema: unknown type: %T", typ) } @@ -456,6 +490,69 @@ start: return &p, nil } +// fillGenericsSchema fills the schema with the generic type information. As the +// types can be different for every generics declaration they will need to be a +// anonymos object in the schema output instead of a reusable reference. +func fillGenericsSchema( + prog *Program, + p *Schema, + tagName string, + ref Reference, + genericsIdent *ast.Ident, + generics map[string]string, + indices ...ast.Expr, +) error { + genericsType, _, _, err := findType(ref.File, ref.Package, genericsIdent.Name) + if err != nil { + return fmt.Errorf("cannot find generic type: %v", err) + } + + var genericsTemplateIDs []string + for _, item := range genericsType.TypeParams.List { + for _, name := range item.Names { + genericsTemplateIDs = append(genericsTemplateIDs, name.Name) + } + } + + if generics == nil { + generics = make(map[string]string) + } + if len(genericsTemplateIDs) > 0 { + if len(indices) != len(genericsTemplateIDs) { + return fmt.Errorf("generic type has %d template IDs, but %d arguments were provided", + len(genericsTemplateIDs), len(indices)) + } + for i := 0; i < len(indices); i++ { + arg, _, err := findTypeIdent(indices[i], ref.Package) + if err != nil { + return fmt.Errorf("cannot find generic type argument: %v", err) + } + generics[genericsTemplateIDs[i]] = arg.Name + } + } + + genericsStruct, ok := genericsType.Type.(*ast.StructType) + if !ok { + return fmt.Errorf("generic type is not a struct: %T", genericsType.Type) + } + + p.Type = "object" + if p.Properties == nil { + p.Properties = make(map[string]*Schema) + } + + for _, field := range genericsStruct.Fields.List { + fieldName := goutil.TagName(field, tagName) + schema, err := fieldToSchema(prog, fieldName, tagName, ref, field, generics) + if err != nil { + return fmt.Errorf("generic fieldToSchema: %v", err) + } + p.Properties[fieldName] = schema + } + + return nil +} + // Helper function to extract enum variations from a file. func getEnumVariations(currentFile, pkgPath, typeName string) ([]string, error) { resolvedPath, pkg, err := resolvePackage(currentFile, pkgPath) @@ -532,7 +629,15 @@ func lookupTypeAndRef(file, pkg, name string) (string, string, error) { return t, sRef, nil } -func resolveArray(prog *Program, ref Reference, pkg string, p *Schema, typ ast.Expr, isEnum bool) error { +func resolveArray( + prog *Program, + ref Reference, + pkg string, + p *Schema, + typ ast.Expr, + isEnum bool, + generics map[string]string, +) error { asw := typ var name *ast.Ident @@ -550,7 +655,11 @@ arrayStart: dbg("resolveArray: ident: %#v in %#v", typ.Name, pkg) - p.Items = &Schema{Type: JSONSchemaType(typ.Name)} + if generics != nil && generics[typ.Name] != "" { + p.Items = &Schema{Type: JSONSchemaType(generics[typ.Name])} + } else { + p.Items = &Schema{Type: JSONSchemaType(typ.Name)} + } // Generally an item is an enum rather than the array itself if len(p.Enum) > 0 { @@ -671,7 +780,7 @@ func JSONSchemaType(t string) string { return t } -func getTypeInfo(prog *Program, lookup, filePath string) (string, error) { +func getTypeInfo(_ *Program, lookup, filePath string) (string, error) { // TODO: REMOVE THE prog PARAM, as this function is not // using it anymore. dbg("getTypeInfo: %#v in %#v", lookup, filePath) diff --git a/docparse/jsonschema_test.go b/docparse/jsonschema_test.go index d7c4d3d..cf74ac8 100644 --- a/docparse/jsonschema_test.go +++ b/docparse/jsonschema_test.go @@ -55,7 +55,7 @@ func TestFieldToProperty(t *testing.T) { Package: "a", File: "./testdata/src/a/a.go", Context: "req", - }, f) + }, f, nil) if err != nil { t.Fatal(err) } @@ -91,7 +91,7 @@ func TestFieldToProperty(t *testing.T) { out, err := fieldToSchema(prog, f.Names[0].Name, "json", Reference{ Package: "a", File: "./testdata/src/a/a.go", - }, f) + }, f, nil) if err != nil { t.Fatal(err) } diff --git a/testdata/openapi2/src/generics/in.go b/testdata/openapi2/src/generics/in.go new file mode 100644 index 0000000..9b54a51 --- /dev/null +++ b/testdata/openapi2/src/generics/in.go @@ -0,0 +1,26 @@ +package generics + +type myGeneric[T, N any] struct { + // This is a simple field. + Field1 T + // This is a array field. + Field2 []T + // This is a map field. + Field3 map[string]T + // This is another simple field. + Field4 int + // This is a different tag field. + Field5 N `json:"hello5"` + // This is a different tag field with pointer. + Field6 *N `json:"hello6"` +} + +type reqRef struct { + // Foo documents a generic type. + Foo myGeneric[string, float64] +} + +// POST /path +// +// Request body: reqRef +// Response 200: {empty} diff --git a/testdata/openapi2/src/generics/want.yaml b/testdata/openapi2/src/generics/want.yaml new file mode 100644 index 0000000..f2d1b5e --- /dev/null +++ b/testdata/openapi2/src/generics/want.yaml @@ -0,0 +1,56 @@ +swagger: "2.0" +info: + title: x + version: x +consumes: + - application/json +produces: + - application/json +paths: + /path: + post: + operationId: POST_path + consumes: + - application/json + produces: + - application/json + parameters: + - name: generics.reqRef + in: body + required: true + schema: + $ref: '#/definitions/generics.reqRef' + responses: + 200: + description: 200 OK (no data) +definitions: + generics.reqRef: + title: reqRef + type: object + properties: + Foo: + description: Foo documents a generic type. + type: object + properties: + Field1: + description: This is a simple field. + type: string + Field2: + description: This is a array field. + type: array + items: + type: string + Field3: + description: This is a map field. + type: object + additionalProperties: + type: string + Field4: + description: This is another simple field. + type: integer + hello5: + description: This is a different tag field. + type: number + hello6: + description: This is a different tag field with pointer. + type: number