| // Copyright 2026 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| package api | |
| import ( | |
| "context" | |
| _ "embed" | |
| "encoding/json" | |
| "flag" | |
| "fmt" | |
| "go/ast" | |
| "go/parser" | |
| "go/token" | |
| "maps" | |
| "os" | |
| "reflect" | |
| "regexp" | |
| "slices" | |
| "strings" | |
| "testing" | |
| "golang.org/x/pkgsite/internal/testing/testhelper" | |
| ) | |
| func TestGenerateSchemas(t *testing.T) { | |
| tests := []struct { | |
| name string | |
| data string | |
| want string | |
| }{ | |
| { | |
| name: "basic types", | |
| data: ` | |
| package api | |
| type Basic struct { | |
| Field1 string ` + "`" + `json:"field1"` + "`" + ` | |
| Field2 bool ` + "`" + `json:"field2"` + "`" + ` | |
| } | |
| `, | |
| want: `{ | |
| "Basic": { | |
| "properties": { | |
| "field1": { | |
| "type": "string" | |
| }, | |
| "field2": { | |
| "type": "boolean" | |
| } | |
| }, | |
| "type": "object" | |
| } | |
| }`, | |
| }, | |
| { | |
| name: "pointers and arrays", | |
| data: ` | |
| package api | |
| type Complex struct { | |
| PtrField *Readme ` + "`" + `json:"ptrField"` + "`" + ` | |
| ArrField []License ` + "`" + `json:"arrField"` + "`" + ` | |
| } | |
| `, | |
| want: `{ | |
| "Complex": { | |
| "properties": { | |
| "arrField": { | |
| "items": { | |
| "$ref": "#/components/schemas/License" | |
| }, | |
| "type": "array" | |
| }, | |
| "ptrField": { | |
| "$ref": "#/components/schemas/Readme" | |
| } | |
| }, | |
| "type": "object" | |
| } | |
| }`, | |
| }, | |
| { | |
| name: "paginated concrete variant", | |
| data: ` | |
| package api | |
| type PaginatedResponse[T any] struct { | |
| Items []T ` + "`" + `json:"items"` + "`" + ` | |
| NextPageToken string ` + "`" + `json:"nextPageToken,omitempty"` + "`" + ` | |
| } | |
| type Holder struct { | |
| List PaginatedResponse[Symbol] ` + "`" + `json:"list"` + "`" + ` | |
| } | |
| type Symbol struct { | |
| Name string ` + "`" + `json:"name"` + "`" + ` | |
| } | |
| `, | |
| want: `{ | |
| "Holder": { | |
| "properties": { | |
| "list": { | |
| "$ref": "#/components/schemas/PaginatedResponse_Symbol" | |
| } | |
| }, | |
| "type": "object" | |
| }, | |
| "PaginatedResponse_Symbol": { | |
| "properties": { | |
| "items": { | |
| "items": { | |
| "$ref": "#/components/schemas/Symbol" | |
| }, | |
| "type": "array" | |
| }, | |
| "nextPageToken": { | |
| "type": "string" | |
| } | |
| }, | |
| "type": "object" | |
| }, | |
| "Symbol": { | |
| "properties": { | |
| "name": { | |
| "type": "string" | |
| } | |
| }, | |
| "type": "object" | |
| } | |
| }`, | |
| }, | |
| { | |
| name: "embedded struct", | |
| data: ` | |
| package api | |
| type Package struct { | |
| Version string ` + "`" + `json:"version"` + "`" + ` | |
| PackageInfo | |
| } | |
| type PackageInfo struct { | |
| Path string ` + "`" + `json:"path"` + "`" + ` | |
| Synopsis string ` + "`" + `json:"synopsis"` + "`" + ` | |
| } | |
| `, | |
| want: `{ | |
| "Package": { | |
| "properties": { | |
| "path": { | |
| "type": "string" | |
| }, | |
| "synopsis": { | |
| "type": "string" | |
| }, | |
| "version": { | |
| "type": "string" | |
| } | |
| }, | |
| "type": "object" | |
| }, | |
| "PackageInfo": { | |
| "properties": { | |
| "path": { | |
| "type": "string" | |
| }, | |
| "synopsis": { | |
| "type": "string" | |
| } | |
| }, | |
| "type": "object" | |
| } | |
| }`, | |
| }, | |
| { | |
| name: "instantiated generic", | |
| data: ` | |
| package api | |
| type PackageImportedBy struct { | |
| ImportedBy PaginatedResponse[string] ` + "`" + `json:"importedBy"` + "`" + ` | |
| } | |
| type PaginatedResponse[T any] struct { | |
| Items []T ` + "`" + `json:"items"` + "`" + ` | |
| NextPageToken string ` + "`" + `json:"nextPageToken,omitempty"` + "`" + ` | |
| } | |
| `, | |
| want: `{ | |
| "PackageImportedBy": { | |
| "properties": { | |
| "importedBy": { | |
| "$ref": "#/components/schemas/PaginatedResponse_string" | |
| } | |
| }, | |
| "type": "object" | |
| }, | |
| "PaginatedResponse_string": { | |
| "properties": { | |
| "items": { | |
| "items": { | |
| "type": "string" | |
| }, | |
| "type": "array" | |
| }, | |
| "nextPageToken": { | |
| "type": "string" | |
| } | |
| }, | |
| "type": "object" | |
| } | |
| }`, | |
| }, | |
| } | |
| for _, tt := range tests { | |
| t.Run(tt.name, func(t *testing.T) { | |
| got, err := generateSchemas([]byte(tt.data), nil) | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| data, err := json.MarshalIndent(got, "", " ") | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if got := string(data); got != tt.want { | |
| t.Errorf("generateSchemas() =\n%s\nwant:\n%s", got, tt.want) | |
| } | |
| }) | |
| } | |
| } | |
| func TestCollectTags(t *testing.T) { | |
| got := collectTags([]*RouteInfo{ | |
| {Route: "/a", Tags: []string{"packages"}}, | |
| {Route: "/b", Tags: []string{"module", "packages"}}, | |
| }) | |
| want := []openAPITag{ | |
| {Name: "module"}, | |
| {Name: "packages"}, | |
| } | |
| if !slices.Equal(got, want) { | |
| t.Errorf("collectTags() = %v, want %v", got, want) | |
| } | |
| } | |
| func TestValidateRefs(t *testing.T) { | |
| schemas := map[string]any{"Known": map[string]any{}} | |
| t.Run("resolved", func(t *testing.T) { | |
| doc := map[string]any{ | |
| "a": []any{map[string]any{"$ref": "#/components/schemas/Known"}}, | |
| } | |
| if err := validateRefs(doc, schemas); err != nil { | |
| t.Errorf("validateRefs() = %v, want nil", err) | |
| } | |
| }) | |
| t.Run("dangling", func(t *testing.T) { | |
| doc := map[string]any{ | |
| "a": []any{map[string]any{"$ref": "#/components/schemas/Missing"}}, | |
| } | |
| if err := validateRefs(doc, schemas); err == nil { | |
| t.Error("validateRefs() = nil, want error for dangling reference") | |
| } | |
| }) | |
| } | |
| var update = flag.Bool("update", false, "update goldens instead of checking against them") | |
| func TestGenerateOpenAPI(t *testing.T) { | |
| got, err := GenerateOpenAPI() | |
| if err != nil { | |
| t.Fatal(err) | |
| } | |
| if !*update { | |
| oldBytes, err := os.ReadFile("openapi.yaml") | |
| if err == nil { | |
| oldStr := string(oldBytes) | |
| oldVersion := extractVersion(oldStr) | |
| newVersion := extractVersion(got) | |
| normOld := normalizeSpec(oldStr) | |
| normNew := normalizeSpec(got) | |
| if normOld != normNew && oldVersion == newVersion { | |
| t.Errorf("API spec changed but version was not bumped from %s. Please update apiVersion in openapi_test.go or run with -update to bypass.", oldVersion) | |
| } | |
| } | |
| } | |
| testhelper.CompareWithGolden(t, got, "../openapi.yaml", *update) | |
| } | |
| func extractVersion(s string) string { | |
| re := regexp.MustCompile(`(?m)^ version: (.*)$`) | |
| matches := re.FindStringSubmatch(s) | |
| if len(matches) > 1 { | |
| return strings.Trim(matches[1], `"`) | |
| } | |
| return "" | |
| } | |
| func normalizeSpec(s string) string { | |
| re := regexp.MustCompile(`(?m)^ version: .*$`) | |
| return re.ReplaceAllString(s, " version: __VERSION__") | |
| } | |
| //go:embed types.go | |
| var typesGo []byte | |
| type openAPISpec struct { | |
| OpenAPI string `json:"openapi"` | |
| Info openAPIInfo `json:"info"` | |
| Servers []openAPIServer `json:"servers"` | |
| Tags []openAPITag `json:"tags"` | |
| Paths map[string]any `json:"paths"` | |
| Components openAPIComponents `json:"components"` | |
| } | |
| type openAPITag struct { | |
| Name string `json:"name"` | |
| } | |
| type openAPIInfo struct { | |
| Title string `json:"title"` | |
| Version string `json:"version"` | |
| Description string `json:"description"` | |
| Contact openAPIContact `json:"contact"` | |
| } | |
| type openAPIContact struct { | |
| Name string `json:"name"` | |
| URL string `json:"url"` | |
| Email string `json:"email"` | |
| } | |
| type openAPIServer struct { | |
| URL string `json:"url"` | |
| } | |
| type openAPIComponents struct { | |
| Schemas map[string]any `json:"schemas"` | |
| } | |
| // GenerateOpenAPI generates an OpenAPI 3.0 specification in JSON format | |
| // (which is valid YAML) based on the routes returned by RouteInfos. | |
| func GenerateOpenAPI() (string, error) { | |
| const ( | |
| openAPISpecVersion = "3.0.3" | |
| apiVersion = "v1.0.0" | |
| apiPathPrefix = "/v1" | |
| ) | |
| routes, err := RouteInfos(context.TODO(), "") | |
| if err != nil { | |
| return "", err | |
| } | |
| tags := collectTags(routes) | |
| spec := openAPISpec{ | |
| OpenAPI: openAPISpecVersion, | |
| Info: openAPIInfo{ | |
| Title: "Go Pkgsite API", | |
| Version: apiVersion, | |
| Description: "API for accessing information about Go packages and modules on pkg.go.dev.", | |
| Contact: openAPIContact{ | |
| Name: "The Go team at Google", | |
| URL: "https://go.dev/s/discovery-feedback", | |
| Email: "golang-dev@googlegroups.com", | |
| }, | |
| }, | |
| Servers: []openAPIServer{ | |
| {URL: "https://pkg.go.dev" + apiPathPrefix}, | |
| }, | |
| Tags: tags, | |
| Paths: make(map[string]any), | |
| } | |
| for _, r := range routes { | |
| path := r.Route | |
| path = strings.TrimPrefix(path, apiPathPrefix) | |
| if !strings.HasPrefix(path, "/") { | |
| path = "/" + path | |
| } | |
| operation := map[string]any{ | |
| "summary": r.Summary, | |
| "description": r.Desc, | |
| "operationId": generateOperationID(path), | |
| "tags": r.Tags, | |
| } | |
| params := []map[string]any{} | |
| for _, p := range r.PathParams { | |
| params = append(params, map[string]any{ | |
| "name": p.Name, | |
| "in": "path", | |
| "required": true, | |
| "description": p.Doc, | |
| "schema": map[string]any{ | |
| "type": "string", | |
| }, | |
| }) | |
| } | |
| for _, p := range r.QueryParams { | |
| params = append(params, map[string]any{ | |
| "name": p.Name, | |
| "in": "query", | |
| "description": p.Doc, | |
| "schema": map[string]any{ | |
| "type": mapType(p.Type), | |
| }, | |
| }) | |
| } | |
| if len(params) > 0 { | |
| operation["parameters"] = params | |
| } | |
| responses := map[string]any{ | |
| "200": map[string]any{ | |
| "description": "Successful response", | |
| }, | |
| "default": map[string]any{ | |
| "description": "Error response", | |
| "content": map[string]any{ | |
| "application/json": map[string]any{ | |
| "schema": map[string]any{ | |
| "$ref": "#/components/schemas/Error", | |
| }, | |
| }, | |
| }, | |
| }, | |
| } | |
| if r.ResponsePaginatedType != "" { | |
| responses["200"].(map[string]any)["content"] = map[string]any{ | |
| "application/json": map[string]any{ | |
| "schema": map[string]any{ | |
| "$ref": "#/components/schemas/" + paginatedSchemaName(r.ResponsePaginatedType), | |
| }, | |
| }, | |
| } | |
| } else if r.Response != "" { | |
| responses["200"].(map[string]any)["content"] = map[string]any{ | |
| "application/json": map[string]any{ | |
| "schema": map[string]any{ | |
| "$ref": "#/components/schemas/" + r.Response, | |
| }, | |
| }, | |
| } | |
| } | |
| operation["responses"] = responses | |
| spec.Paths[path] = map[string]any{ | |
| "get": operation, | |
| } | |
| } | |
| var paginatedElems []string | |
| for _, r := range routes { | |
| if r.ResponsePaginatedType != "" { | |
| paginatedElems = append(paginatedElems, r.ResponsePaginatedType) | |
| } | |
| } | |
| schemas, err := generateSchemas(typesGo, paginatedElems) | |
| if err != nil { | |
| return "", err | |
| } | |
| spec.Components.Schemas = schemas | |
| data, err := json.MarshalIndent(spec, "", " ") | |
| if err != nil { | |
| return "", err | |
| } | |
| var doc any | |
| if err := json.Unmarshal(data, &doc); err != nil { | |
| return "", err | |
| } | |
| if err := validateRefs(doc, schemas); err != nil { | |
| return "", err | |
| } | |
| return string(data), nil | |
| } | |
| // validateRefs walks the decoded spec and returns an error for any | |
| // "#/components/schemas/..." reference that has no matching schema, so that a | |
| // dangling $ref (e.g. a paginated element type or response type with no struct) | |
| // fails generation instead of producing an invalid spec. | |
| func validateRefs(v any, schemas map[string]any) error { | |
| switch v := v.(type) { | |
| case map[string]any: | |
| for key, val := range v { | |
| if key == "$ref" { | |
| ref, ok := val.(string) | |
| if !ok { | |
| continue | |
| } | |
| name, ok := strings.CutPrefix(ref, "#/components/schemas/") | |
| if !ok { | |
| continue | |
| } | |
| if _, ok := schemas[name]; !ok { | |
| return fmt.Errorf("unresolved schema reference %q", ref) | |
| } | |
| continue | |
| } | |
| if err := validateRefs(val, schemas); err != nil { | |
| return err | |
| } | |
| } | |
| case []any: | |
| for _, item := range v { | |
| if err := validateRefs(item, schemas); err != nil { | |
| return err | |
| } | |
| } | |
| } | |
| return nil | |
| } | |
| func generateSchemas(data []byte, paginatedElems []string) (map[string]any, error) { | |
| fset := token.NewFileSet() | |
| file, err := parser.ParseFile(fset, "", data, parser.ParseComments) | |
| if err != nil { | |
| return nil, err | |
| } | |
| structs := make(map[string]*ast.StructType) | |
| for _, decl := range file.Decls { | |
| genDecl, ok := decl.(*ast.GenDecl) | |
| if !ok || genDecl.Tok != token.TYPE { | |
| continue | |
| } | |
| for _, spec := range genDecl.Specs { | |
| typeSpec, ok := spec.(*ast.TypeSpec) | |
| if !ok { | |
| continue | |
| } | |
| typeName := typeSpec.Name.Name | |
| if structType, ok := typeSpec.Type.(*ast.StructType); ok { | |
| structs[typeName] = structType | |
| } | |
| } | |
| } | |
| schemas := make(map[string]any) | |
| for name, structType := range structs { | |
| // PaginatedResponse is generic; concrete variants are emitted by | |
| // addPaginatedSchemas, so skip the generic base here. | |
| if name == "PaginatedResponse" { | |
| continue | |
| } | |
| properties := make(map[string]any) | |
| collectProperties(structType, structs, properties) | |
| schemas[name] = map[string]any{ | |
| "type": "object", | |
| "properties": properties, | |
| } | |
| } | |
| addPaginatedSchemas(schemas, structs, paginatedElems) | |
| return schemas, nil | |
| } | |
| // addPaginatedSchemas adds a concrete schema for each PaginatedResponse[T] | |
| // instantiation, so that "items" references the actual element type instead of | |
| // the generic object. Element types come both from struct fields and from the | |
| // paginated response types declared by routes. | |
| func addPaginatedSchemas(schemas map[string]any, structs map[string]*ast.StructType, paginatedElems []string) { | |
| base, ok := structs["PaginatedResponse"] | |
| if !ok { | |
| return | |
| } | |
| elems := map[string]bool{} | |
| for _, elem := range paginatedElems { | |
| elems[elem] = true | |
| } | |
| for _, st := range structs { | |
| for _, field := range st.Fields.List { | |
| if elem, ok := paginatedElem(typeExprToString(field.Type)); ok { | |
| elems[elem] = true | |
| } | |
| } | |
| } | |
| for elem := range elems { | |
| properties := make(map[string]any) | |
| collectProperties(base, structs, properties) | |
| properties["items"] = map[string]any{ | |
| "type": "array", | |
| "items": elemSchema(elem), | |
| } | |
| schemas[paginatedSchemaName(elem)] = map[string]any{ | |
| "type": "object", | |
| "properties": properties, | |
| } | |
| } | |
| } | |
| // paginatedElem reports whether t is a PaginatedResponse[E] type and returns E. | |
| func paginatedElem(t string) (string, bool) { | |
| rest, ok := strings.CutPrefix(t, "PaginatedResponse[") | |
| if !ok || !strings.HasSuffix(rest, "]") { | |
| return "", false | |
| } | |
| return strings.TrimSuffix(rest, "]"), true | |
| } | |
| // paginatedSchemaName returns the component schema name for PaginatedResponse[elem]. | |
| func paginatedSchemaName(elem string) string { | |
| return "PaginatedResponse_" + elem | |
| } | |
| // elemSchema returns the OpenAPI schema for a single element of an array or | |
| // paginated response with the given element type. | |
| func elemSchema(elem string) map[string]any { | |
| switch elem { | |
| case "string", "bool", "int": | |
| return map[string]any{"type": mapType(elem)} | |
| case "T": | |
| return map[string]any{"type": "object"} | |
| default: | |
| return map[string]any{"$ref": "#/components/schemas/" + elem} | |
| } | |
| } | |
| // collectProperties adds the schema property for each field of st to properties, | |
| // recursing into embedded structs so their fields are promoted to the parent. | |
| func collectProperties(st *ast.StructType, structs map[string]*ast.StructType, properties map[string]any) { | |
| for _, field := range st.Fields.List { | |
| if field.Names == nil { | |
| if embedded, ok := structs[typeExprToString(field.Type)]; ok { | |
| collectProperties(embedded, structs, properties) | |
| } | |
| continue | |
| } | |
| if !field.Names[0].IsExported() { | |
| continue | |
| } | |
| fieldName := field.Names[0].Name | |
| tag := "" | |
| if field.Tag != nil { | |
| tag = field.Tag.Value | |
| } | |
| jsonName := extractJSONName(tag) | |
| if jsonName == "" { | |
| jsonName = fieldName | |
| } | |
| prop := mapFieldType(typeExprToString(field.Type)) | |
| if field.Doc != nil { | |
| prop["description"] = strings.TrimSpace(field.Doc.Text()) | |
| } else if field.Comment != nil { | |
| prop["description"] = strings.TrimSpace(field.Comment.Text()) | |
| } | |
| properties[jsonName] = prop | |
| } | |
| } | |
| func mapFieldType(t string) map[string]any { | |
| switch t { | |
| case "string": | |
| return map[string]any{"type": "string"} | |
| case "time.Time": | |
| return map[string]any{"type": "string", "format": "date-time"} | |
| case "bool": | |
| return map[string]any{"type": "boolean"} | |
| case "int": | |
| return map[string]any{"type": "integer"} | |
| default: | |
| if strings.HasPrefix(t, "[]") { | |
| return map[string]any{ | |
| "type": "array", | |
| "items": elemSchema(t[2:]), | |
| } | |
| } else if strings.HasPrefix(t, "*") { | |
| elem := t[1:] | |
| return map[string]any{"$ref": "#/components/schemas/" + elem} | |
| } else if elem, ok := paginatedElem(t); ok { | |
| return map[string]any{"$ref": "#/components/schemas/" + paginatedSchemaName(elem)} | |
| } else { | |
| return map[string]any{"$ref": "#/components/schemas/" + t} | |
| } | |
| } | |
| } | |
| func extractJSONName(tag string) string { | |
| if tag == "" { | |
| return "" | |
| } | |
| tag = strings.Trim(tag, "`") | |
| structTag := reflect.StructTag(tag) | |
| jsonVal := structTag.Get("json") | |
| name, _, _ := strings.Cut(jsonVal, ",") | |
| return name | |
| } | |
| func typeExprToString(expr ast.Expr) string { | |
| switch e := expr.(type) { | |
| case *ast.Ident: | |
| return e.Name | |
| case *ast.ArrayType: | |
| return "[]" + typeExprToString(e.Elt) | |
| case *ast.StarExpr: | |
| return "*" + typeExprToString(e.X) | |
| case *ast.IndexExpr: | |
| // Handle generic types like PaginatedResponse[SearchResult] | |
| return typeExprToString(e.X) + "[" + typeExprToString(e.Index) + "]" | |
| case *ast.SelectorExpr: | |
| return typeExprToString(e.X) + "." + e.Sel.Name | |
| default: | |
| return fmt.Sprintf("%T", expr) | |
| } | |
| } | |
| func generateOperationID(path string) string { | |
| var sb strings.Builder | |
| sb.WriteString("get") | |
| for p := range strings.SplitSeq(path, "/") { | |
| if p == "" || strings.HasPrefix(p, "{") { | |
| continue | |
| } | |
| if len(p) > 0 { | |
| sb.WriteString(strings.ToUpper(p[:1])) | |
| sb.WriteString(p[1:]) | |
| } | |
| } | |
| return sb.String() | |
| } | |
| // collectTags returns the global tags definition for all tags used by routes, | |
| // sorted by name. Descriptions are left empty. | |
| func collectTags(routes []*RouteInfo) []openAPITag { | |
| tags := make(map[string]openAPITag) | |
| for _, r := range routes { | |
| for _, name := range r.Tags { | |
| tags[name] = openAPITag{Name: name} | |
| } | |
| } | |
| return slices.SortedFunc(maps.Values(tags), func(a, b openAPITag) int { | |
| return strings.Compare(a.Name, b.Name) | |
| }) | |
| } | |
| func mapType(t string) string { | |
| switch t { | |
| case "bool": | |
| return "boolean" | |
| case "int": | |
| return "integer" | |
| default: | |
| return "string" | |
| } | |
| } |