internal/api: fix OpenAPI 3.0 validation issues

The generated OpenAPI spec triggered several spectral lint warnings.
Fix them by completing the generator metadata:

  - Add contact info.
  - Add a tag and a description to every operation.
  - Generate a concrete schema for each PaginatedResponse[T] so "items"
    references the actual element type instead of a generic object.
  - Reference the Error schema from a default error response so it is no
    longer reported as unused.

Additionally, update readRouteInfo to detect duplicate api:route
entries, as required by tests.

Fixes golang/go#80009

Change-Id: Ic0c5e848567481b28eb71102031bb483d7e3ed38
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/791580
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
kokoro-CI: kokoro <noreply+kokoro@google.com>
Auto-Submit: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/internal/api/api.go b/internal/api/api.go
index eab4434..6e2beee 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -589,8 +589,8 @@
 
 // ServeVulnerabilities handles requests for the v1beta vulnerabilities endpoint.
 // api:route /v1beta/vulns/{path}
-// api:desc Vulnerabilities of the module or package at {path}, from
-// api:desc the Go vulnerability database (https://vuln.go.dev).
+// api:desc Vulnerabilities of the module or package at {path}.
+// api:desc Data comes from the Go vulnerability database (https://vuln.go.dev).
 // api:desc Only results that match the filter query parameter are returned.
 // api:example /v1beta/vulns/golang.org/x/image
 func ServeVulnerabilities(vc *vuln.Client) func(w http.ResponseWriter, r *http.Request, _ internal.DataSource) error {
diff --git a/internal/api/docpage.go b/internal/api/docpage.go
index 1d0dff0..af1b37e 100644
--- a/internal/api/docpage.go
+++ b/internal/api/docpage.go
@@ -20,6 +20,7 @@
 	"net/url"
 	"reflect"
 	"regexp"
+	"slices"
 	"strings"
 	"sync"
 	"time"
@@ -50,6 +51,8 @@
 // RouteInfo contains documentation information for an API route.
 type RouteInfo struct {
 	Route                 string
+	Tags                  []string
+	Summary               string
 	Desc                  string
 	Params                string
 	Response              string
@@ -214,6 +217,17 @@
 // routePlaceholderRE matches path placeholders in a route, e.g. {path} in /v1beta/module/{path}.
 var routePlaceholderRE = regexp.MustCompile(`\{([^}]+)\}`)
 
+// routeTag returns the tag for a route: the path element after the first.
+// For example, the tag for "/v1beta/package/{path}" is "package".
+// It returns "default" when the route has no such element.
+func routeTag(route string) string {
+	parts := strings.Split(strings.Trim(route, "/"), "/")
+	if len(parts) < 2 {
+		return "default"
+	}
+	return parts[1]
+}
+
 // readRouteInfo reads the provided Go source data and returns documentation information for all routes.
 func readRouteInfo(data []byte, paramsMap map[string][]QueryParam) ([]*RouteInfo, error) {
 	var routes []*RouteInfo
@@ -226,9 +240,14 @@
 		if r.Route == "" {
 			return errors.New("missing api:route")
 		}
+		if slices.ContainsFunc(routes, func(ex *RouteInfo) bool { return ex.Route == r.Route }) {
+			return fmt.Errorf("duplicate api:route %q", r.Route)
+		}
 		if r.Desc == "" {
 			return fmt.Errorf("missing api:desc field in route %q", r.Route)
 		}
+		r.Tags = []string{routeTag(r.Route)}
+		r.Summary, _, _ = strings.Cut(r.Desc, ".")
 		if r.Params == "" {
 			return fmt.Errorf("missing api:params field in route %q", r.Route)
 		}
diff --git a/internal/api/docpage_test.go b/internal/api/docpage_test.go
index 51c6a6d..c5830af 100644
--- a/internal/api/docpage_test.go
+++ b/internal/api/docpage_test.go
@@ -52,6 +52,8 @@
 			want: []*RouteInfo{
 				{
 					Route:    "/v1beta/dummy",
+					Tags:     []string{"dummy"},
+					Summary:  "Dummy route",
 					Desc:     "Dummy route.",
 					Params:   "DummyParams",
 					Response: "DummyResponse",
@@ -79,6 +81,8 @@
 			want: []*RouteInfo{
 				{
 					Route:    "/v1beta/dummy-complex",
+					Tags:     []string{"dummy-complex"},
+					Summary:  "Dummy complex route",
 					Desc:     "Dummy complex route.",
 					Params:   "DummyComplexParams",
 					Response: "DummyComplexResponse",
@@ -106,6 +110,8 @@
 			want: []*RouteInfo{
 				{
 					Route:      "/v1beta/package/{path}",
+					Tags:       []string{"package"},
+					Summary:    "Get package metadata",
 					Desc:       "Get package metadata.",
 					Params:     "path, version, module",
 					Response:   "Package",
@@ -113,6 +119,8 @@
 				},
 				{
 					Route:      "/v1beta/module/{path}",
+					Tags:       []string{"module"},
+					Summary:    "Get module metadata",
 					Desc:       "Get module metadata.",
 					Params:     "path, version",
 					Response:   "Module",
@@ -132,6 +140,8 @@
 			want: []*RouteInfo{
 				{
 					Route:                 "/v1beta/versions/{path}",
+					Tags:                  []string{"versions"},
+					Summary:               "All versions of the module at {path}",
 					Desc:                  "All versions of the module at {path}.",
 					Params:                "filter, limit, token",
 					Response:              "PaginatedResponse[ModuleInfo]",
@@ -152,6 +162,8 @@
 			want: []*RouteInfo{
 				{
 					Route:                 "/v1beta/strings",
+					Tags:                  []string{"strings"},
+					Summary:               "Some strings",
 					Desc:                  "Some strings.",
 					Params:                "filter",
 					Response:              "PaginatedResponse[string]",
@@ -161,6 +173,52 @@
 			},
 		},
 		{
+			name: "multi-sentence description",
+			data: `
+//api:route /v1beta/vulns/{path}
+//api:pathparam path Module or package path.
+//api:desc Vulnerabilities of the module or package at {path}.
+//api:desc Data comes from the Go vulnerability database.
+//api:desc Only results that match the filter query parameter are returned.
+//api:params filter
+//api:response PaginatedResponse[Vulnerability]
+`,
+			want: []*RouteInfo{
+				{
+					Route:                 "/v1beta/vulns/{path}",
+					Tags:                  []string{"vulns"},
+					Summary:               "Vulnerabilities of the module or package at {path}",
+					Desc:                  "Vulnerabilities of the module or package at {path}.\nData comes from the Go vulnerability database.\nOnly results that match the filter query parameter are returned.",
+					Params:                "filter",
+					Response:              "PaginatedResponse[Vulnerability]",
+					ResponsePaginatedType: "Vulnerability",
+					LinkPaginatedType:     true,
+					PathParams:            []PathParam{{Name: "path", Doc: "Module or package path."}},
+				},
+			},
+		},
+		{
+			name: "multiple sentences on first line",
+			data: `
+//api:route /v1beta/search
+//api:desc Search results. Only matching results are returned.
+//api:params filter
+//api:response PaginatedResponse[SearchResult]
+`,
+			want: []*RouteInfo{
+				{
+					Route:                 "/v1beta/search",
+					Tags:                  []string{"search"},
+					Summary:               "Search results",
+					Desc:                  "Search results. Only matching results are returned.",
+					Params:                "filter",
+					Response:              "PaginatedResponse[SearchResult]",
+					ResponsePaginatedType: "SearchResult",
+					LinkPaginatedType:     true,
+				},
+			},
+		},
+		{
 			name: "missing field",
 			data: `
 //api:route /v1beta/package/{path}
@@ -197,18 +255,15 @@
 			name: "duplicate route",
 			data: `
 //api:route /v1beta/package/{path}
-//api:route /v1beta/other
 //api:pathparam path Module or package path.
-`,
-			wantErr: true,
-		},
-		{
-			name: "duplicate desc",
-			data: `
-//api:route /v1beta/package/{path}
 //api:desc Get package metadata.
-//api:desc Something else.
+//api:params DummyParams
+//api:response Package
+//api:route /v1beta/package/{path}
 //api:pathparam path Module or package path.
+//api:desc Get package metadata.
+//api:params DummyParams
+//api:response Package
 `,
 			wantErr: true,
 		},
diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml
index b2f733d..81cf5c1 100644
--- a/internal/api/openapi.yaml
+++ b/internal/api/openapi.yaml
@@ -3,16 +3,48 @@
   "info": {
     "title": "Go Pkgsite API",
     "version": "v0.1.1",
-    "description": "API for accessing information about Go packages and modules on pkg.go.dev."
+    "description": "API for accessing information about Go packages and modules on pkg.go.dev.",
+    "contact": {
+      "name": "The Go team at Google",
+      "url": "https://go.dev/s/discovery-feedback",
+      "email": "golang-dev@googlegroups.com"
+    }
   },
   "servers": [
     {
       "url": "https://pkg.go.dev/v1beta"
     }
   ],
+  "tags": [
+    {
+      "name": "imported-by"
+    },
+    {
+      "name": "module"
+    },
+    {
+      "name": "package"
+    },
+    {
+      "name": "packages"
+    },
+    {
+      "name": "search"
+    },
+    {
+      "name": "symbols"
+    },
+    {
+      "name": "versions"
+    },
+    {
+      "name": "vulns"
+    }
+  ],
   "paths": {
     "/imported-by/{path}": {
       "get": {
+        "description": "Paths of packages importing the package at {path},\nnot including packages in the same module.\nFiltering is applied to the list of paths in the response.\nOnly paths that match the filter query parameter are returned.\nWithin a filter, the variable `path` is set to the import path.",
         "operationId": "getImported-by",
         "parameters": [
           {
@@ -75,13 +107,27 @@
               }
             },
             "description": "Successful response"
+          },
+          "default": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            },
+            "description": "Error response"
           }
         },
-        "summary": "Paths of packages importing the package at {path},\nnot including packages in the same module.\nFiltering is applied to the list of paths in the response.\nOnly paths that match the filter query parameter are returned.\nWithin a filter, the variable `path` is set to the import path."
+        "summary": "Paths of packages importing the package at {path},\nnot including packages in the same module",
+        "tags": [
+          "imported-by"
+        ]
       }
     },
     "/module/{path}": {
       "get": {
+        "description": "Information about the module at {path}.",
         "operationId": "getModule",
         "parameters": [
           {
@@ -128,13 +174,27 @@
               }
             },
             "description": "Successful response"
+          },
+          "default": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            },
+            "description": "Error response"
           }
         },
-        "summary": "Information about the module at {path}."
+        "summary": "Information about the module at {path}",
+        "tags": [
+          "module"
+        ]
       }
     },
     "/package/{path}": {
       "get": {
+        "description": "Information about the package at {path}.",
         "operationId": "getPackage",
         "parameters": [
           {
@@ -221,13 +281,27 @@
               }
             },
             "description": "Successful response"
+          },
+          "default": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            },
+            "description": "Error response"
           }
         },
-        "summary": "Information about the package at {path}."
+        "summary": "Information about the package at {path}",
+        "tags": [
+          "package"
+        ]
       }
     },
     "/packages/{path}": {
       "get": {
+        "description": "Information about packages of the module at {path}.\nFiltering is applied to the list of packages in the response.\nOnly packages that match the filter query parameter are returned.",
         "operationId": "getPackages",
         "parameters": [
           {
@@ -282,13 +356,27 @@
               }
             },
             "description": "Successful response"
+          },
+          "default": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            },
+            "description": "Error response"
           }
         },
-        "summary": "Information about packages of the module at {path}.\nFiltering is applied to the list of packages in the response.\nOnly packages that match the filter query parameter are returned."
+        "summary": "Information about packages of the module at {path}",
+        "tags": [
+          "packages"
+        ]
       }
     },
     "/search": {
       "get": {
+        "description": "Search results. Only results that match the filter query parameter are returned.\nResults are sorted by how well the match the query, with the best match first.",
         "operationId": "getSearch",
         "parameters": [
           {
@@ -337,18 +425,32 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "$ref": "#/components/schemas/PaginatedResponse"
+                  "$ref": "#/components/schemas/PaginatedResponse_SearchResult"
                 }
               }
             },
             "description": "Successful response"
+          },
+          "default": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            },
+            "description": "Error response"
           }
         },
-        "summary": "Search results. Only results that match the filter query parameter are returned.\nResults are sorted by how well the match the query, with the best match first."
+        "summary": "Search results",
+        "tags": [
+          "search"
+        ]
       }
     },
     "/symbols/{path}": {
       "get": {
+        "description": "List of symbols for the package at {path}.\nFiltering is applied to the list of symbols in the response.\nOnly symbols that match the filter query parameter are returned.",
         "operationId": "getSymbols",
         "parameters": [
           {
@@ -427,13 +529,27 @@
               }
             },
             "description": "Successful response"
+          },
+          "default": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            },
+            "description": "Error response"
           }
         },
-        "summary": "List of symbols for the package at {path}.\nFiltering is applied to the list of symbols in the response.\nOnly symbols that match the filter query parameter are returned."
+        "summary": "List of symbols for the package at {path}",
+        "tags": [
+          "symbols"
+        ]
       }
     },
     "/versions/{path}": {
       "get": {
+        "description": "Versions of the module at {path}.\nIf there are tagged versions, they are returned.\nOtherwise, the 10 most recent pseudo-versions are returned.\nThe versions are in descending order.\nOnly results that match the filter query parameter are returned.",
         "operationId": "getVersions",
         "parameters": [
           {
@@ -475,18 +591,32 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "$ref": "#/components/schemas/PaginatedResponse"
+                  "$ref": "#/components/schemas/PaginatedResponse_ModuleVersion"
                 }
               }
             },
             "description": "Successful response"
+          },
+          "default": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            },
+            "description": "Error response"
           }
         },
-        "summary": "Versions of the module at {path}.\nIf there are tagged versions, they are returned.\nOtherwise, the 10 most recent pseudo-versions are returned.\nThe versions are in descending order.\nOnly results that match the filter query parameter are returned."
+        "summary": "Versions of the module at {path}",
+        "tags": [
+          "versions"
+        ]
       }
     },
     "/vulns/{path}": {
       "get": {
+        "description": "Vulnerabilities of the module or package at {path}.\nData comes from the Go vulnerability database (https://vuln.go.dev).\nOnly results that match the filter query parameter are returned.",
         "operationId": "getVulns",
         "parameters": [
           {
@@ -544,14 +674,27 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "$ref": "#/components/schemas/PaginatedResponse"
+                  "$ref": "#/components/schemas/PaginatedResponse_Vulnerability"
                 }
               }
             },
             "description": "Successful response"
+          },
+          "default": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Error"
+                }
+              }
+            },
+            "description": "Error response"
           }
         },
-        "summary": "Vulnerabilities of the module or package at {path}, from\nthe Go vulnerability database (https://vuln.go.dev).\nOnly results that match the filter query parameter are returned."
+        "summary": "Vulnerabilities of the module or package at {path}",
+        "tags": [
+          "vulns"
+        ]
       }
     }
   },
@@ -746,7 +889,7 @@
       "PackageImportedBy": {
         "properties": {
           "importedBy": {
-            "$ref": "#/components/schemas/PaginatedResponse"
+            "$ref": "#/components/schemas/PaginatedResponse_string"
           },
           "modulePath": {
             "type": "string"
@@ -781,7 +924,7 @@
             "type": "string"
           },
           "symbols": {
-            "$ref": "#/components/schemas/PaginatedResponse"
+            "$ref": "#/components/schemas/PaginatedResponse_Symbol"
           },
           "version": {
             "type": "string"
@@ -798,7 +941,7 @@
             "type": "string"
           },
           "packages": {
-            "$ref": "#/components/schemas/PaginatedResponse"
+            "$ref": "#/components/schemas/PaginatedResponse_PackageInfo"
           },
           "version": {
             "type": "string"
@@ -806,11 +949,96 @@
         },
         "type": "object"
       },
-      "PaginatedResponse": {
+      "PaginatedResponse_ModuleVersion": {
         "properties": {
           "items": {
             "items": {
-              "type": "object"
+              "$ref": "#/components/schemas/ModuleVersion"
+            },
+            "type": "array"
+          },
+          "nextPageToken": {
+            "type": "string"
+          },
+          "total": {
+            "type": "integer"
+          }
+        },
+        "type": "object"
+      },
+      "PaginatedResponse_PackageInfo": {
+        "properties": {
+          "items": {
+            "items": {
+              "$ref": "#/components/schemas/PackageInfo"
+            },
+            "type": "array"
+          },
+          "nextPageToken": {
+            "type": "string"
+          },
+          "total": {
+            "type": "integer"
+          }
+        },
+        "type": "object"
+      },
+      "PaginatedResponse_SearchResult": {
+        "properties": {
+          "items": {
+            "items": {
+              "$ref": "#/components/schemas/SearchResult"
+            },
+            "type": "array"
+          },
+          "nextPageToken": {
+            "type": "string"
+          },
+          "total": {
+            "type": "integer"
+          }
+        },
+        "type": "object"
+      },
+      "PaginatedResponse_Symbol": {
+        "properties": {
+          "items": {
+            "items": {
+              "$ref": "#/components/schemas/Symbol"
+            },
+            "type": "array"
+          },
+          "nextPageToken": {
+            "type": "string"
+          },
+          "total": {
+            "type": "integer"
+          }
+        },
+        "type": "object"
+      },
+      "PaginatedResponse_Vulnerability": {
+        "properties": {
+          "items": {
+            "items": {
+              "$ref": "#/components/schemas/Vulnerability"
+            },
+            "type": "array"
+          },
+          "nextPageToken": {
+            "type": "string"
+          },
+          "total": {
+            "type": "integer"
+          }
+        },
+        "type": "object"
+      },
+      "PaginatedResponse_string": {
+        "properties": {
+          "items": {
+            "items": {
+              "type": "string"
             },
             "type": "array"
           },
diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go
index 194e12f..a3abeec 100644
--- a/internal/api/openapi_test.go
+++ b/internal/api/openapi_test.go
@@ -13,9 +13,11 @@
 	"go/ast"
 	"go/parser"
 	"go/token"
+	"maps"
 	"os"
 	"reflect"
 	"regexp"
+	"slices"
 	"strings"
 	"testing"
 
@@ -78,21 +80,47 @@
 }`,
 		},
 		{
-			name: "generics elision",
+			name: "paginated concrete variant",
 			data: `
 package api
 type PaginatedResponse[T any] struct {
-	Items []T ` + "`" + `json:"items"` + "`" + `
+	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: `{
-  "PaginatedResponse": {
+  "Holder": {
+    "properties": {
+      "list": {
+        "$ref": "#/components/schemas/PaginatedResponse_Symbol"
+      }
+    },
+    "type": "object"
+  },
+  "PaginatedResponse_Symbol": {
     "properties": {
       "items": {
         "items": {
-          "type": "object"
+          "$ref": "#/components/schemas/Symbol"
         },
         "type": "array"
+      },
+      "nextPageToken": {
+        "type": "string"
+      }
+    },
+    "type": "object"
+  },
+  "Symbol": {
+    "properties": {
+      "name": {
+        "type": "string"
       }
     },
     "type": "object"
@@ -112,7 +140,8 @@
 	Synopsis string ` + "`" + `json:"synopsis"` + "`" + `
 }
 `,
-			want: `"Package": {
+			want: `{
+  "Package": {
     "properties": {
       "path": {
         "type": "string"
@@ -125,7 +154,19 @@
       }
     },
     "type": "object"
-  }`,
+  },
+  "PackageInfo": {
+    "properties": {
+      "path": {
+        "type": "string"
+      },
+      "synopsis": {
+        "type": "string"
+      }
+    },
+    "type": "object"
+  }
+}`,
 		},
 		{
 			name: "instantiated generic",
@@ -134,12 +175,30 @@
 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"
+        "$ref": "#/components/schemas/PaginatedResponse_string"
+      }
+    },
+    "type": "object"
+  },
+  "PaginatedResponse_string": {
+    "properties": {
+      "items": {
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "nextPageToken": {
+        "type": "string"
       }
     },
     "type": "object"
@@ -150,7 +209,7 @@
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got, err := generateSchemas([]byte(tt.data))
+			got, err := generateSchemas([]byte(tt.data), nil)
 			if err != nil {
 				t.Fatal(err)
 			}
@@ -158,14 +217,49 @@
 			if err != nil {
 				t.Fatal(err)
 			}
-			gotStr := string(data)
-			if !strings.Contains(gotStr, tt.want) {
-				t.Errorf("generateSchemas output does not contain expected schema.\nWant:\n%s\nGot:\n%s", tt.want, gotStr)
+			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) {
@@ -214,14 +308,26 @@
 	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"`
+	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 {
@@ -246,16 +352,24 @@
 		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),
 	}
 
@@ -267,8 +381,10 @@
 		}
 
 		operation := map[string]any{
-			"summary":     r.Desc,
+			"summary":     r.Summary,
+			"description": r.Desc,
 			"operationId": generateOperationID(path),
+			"tags":        r.Tags,
 		}
 
 		params := []map[string]any{}
@@ -302,13 +418,23 @@
 			"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/PaginatedResponse",
+						"$ref": "#/components/schemas/" + paginatedSchemaName(r.ResponsePaginatedType),
 					},
 				},
 			}
@@ -328,7 +454,14 @@
 		}
 	}
 
-	schemas, err := generateSchemas(typesGo)
+	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
 	}
@@ -339,10 +472,54 @@
 		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
 }
 
-func generateSchemas(data []byte) (map[string]any, error) {
+// 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 {
@@ -369,6 +546,11 @@
 
 	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{
@@ -377,9 +559,74 @@
 		}
 	}
 
+	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) {
@@ -426,25 +673,15 @@
 		return map[string]any{"type": "integer"}
 	default:
 		if strings.HasPrefix(t, "[]") {
-			elem := t[2:]
-			items := map[string]any{}
-			switch elem {
-			case "string", "bool", "int":
-				items["type"] = mapType(elem)
-			case "T":
-				items["type"] = "object"
-			default:
-				items["$ref"] = "#/components/schemas/" + elem
-			}
 			return map[string]any{
 				"type":  "array",
-				"items": items,
+				"items": elemSchema(t[2:]),
 			}
 		} else if strings.HasPrefix(t, "*") {
 			elem := t[1:]
 			return map[string]any{"$ref": "#/components/schemas/" + elem}
-		} else if strings.HasPrefix(t, "PaginatedResponse[") {
-			return map[string]any{"$ref": "#/components/schemas/PaginatedResponse"}
+		} 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}
 		}
@@ -495,6 +732,20 @@
 	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":