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":