| // 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 = "v0.1.1" |
| apiPathPrefix = "/v1beta" |
| ) |
| |
| 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" |
| } |
| } |