package coderdtest import ( "go/ast" "go/parser" "go/token" "net/http" "regexp" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" ) type SwaggerComment struct { summary string id string security string tags string accept string produce string method string router string successes []response failures []response parameters []parameter raw []*ast.Comment } type parameter struct { name string kind string } type response struct { status string kind string // {object} or {array} model string } func ParseSwaggerComments(dirs ...string) ([]SwaggerComment, error) { fileSet := token.NewFileSet() var swaggerComments []SwaggerComment for _, dir := range dirs { nodes, err := parser.ParseDir(fileSet, dir, nil, parser.ParseComments) if err != nil { return nil, xerrors.Errorf(`parser.ParseDir failed for "%s": %w`, dir, err) } for _, node := range nodes { ast.Inspect(node, func(n ast.Node) bool { commentGroup, ok := n.(*ast.CommentGroup) if !ok { return true } var isSwaggerComment bool for _, line := range commentGroup.List { text := strings.TrimSpace(line.Text) if strings.HasPrefix(text, "//") && strings.Contains(text, "@Router") { isSwaggerComment = true break } } if isSwaggerComment { swaggerComments = append(swaggerComments, parseSwaggerComment(commentGroup)) } return true }) } } return swaggerComments, nil } func parseSwaggerComment(commentGroup *ast.CommentGroup) SwaggerComment { c := SwaggerComment{ raw: commentGroup.List, parameters: []parameter{}, successes: []response{}, failures: []response{}, } for _, line := range commentGroup.List { // @ [args...] splitN := strings.SplitN(strings.TrimSpace(line.Text), " ", 3) if len(splitN) < 2 { continue // comment prefix without any content } if !strings.HasPrefix(splitN[1], "@") { continue // not a swagger annotation } annotationName := splitN[1] annotationArgs := splitN[2] args := strings.Split(splitN[2], " ") switch annotationName { case "@Router": c.router = args[0] c.method = args[1][1 : len(args[1])-1] case "@Success", "@Failure": var r response if len(args) > 0 { r.status = args[0] } if len(args) > 1 { r.kind = args[1] } if len(args) > 2 { r.model = args[2] } if annotationName == "@Success" { c.successes = append(c.successes, r) } else if annotationName == "@Failure" { c.failures = append(c.failures, r) } case "@Param": p := parameter{ name: args[0], kind: args[1], } c.parameters = append(c.parameters, p) case "@Summary": c.summary = annotationArgs case "@ID": c.id = annotationArgs case "@Tags": c.tags = annotationArgs case "@Security": c.security = annotationArgs case "@Accept": c.accept = annotationArgs case "@Produce": c.produce = annotationArgs } } return c } func VerifySwaggerDefinitions(t *testing.T, router chi.Router, swaggerComments []SwaggerComment) { assertUniqueRoutes(t, swaggerComments) assertSingleAnnotations(t, swaggerComments) err := chi.Walk(router, func(method, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { method = strings.ToLower(method) if route != "/" && strings.HasSuffix(route, "/") { route = route[:len(route)-1] } t.Run(method+" "+route, func(t *testing.T) { t.Parallel() // This route is for compatibility purposes and is not documented. if route == "/workspaceagents/me/metadata" { return } c := findSwaggerCommentByMethodAndRoute(swaggerComments, method, route) assert.NotNil(t, c, "Missing @Router annotation") if c == nil { return // do not fail next assertion for this route } assertConsistencyBetweenRouteIDAndSummary(t, *c) assertSuccessOrFailureDefined(t, *c) assertRequiredAnnotations(t, *c) assertGoCommentFirst(t, *c) assertPathParametersDefined(t, *c) assertSecurityDefined(t, *c) assertAccept(t, *c) assertProduce(t, *c) }) return nil }) require.NoError(t, err, "chi.Walk should not fail") } func assertUniqueRoutes(t *testing.T, comments []SwaggerComment) { m := map[string]struct{}{} for _, c := range comments { key := c.method + " " + c.router _, alreadyDefined := m[key] assert.False(t, alreadyDefined, "defined route must be unique (method: %s, route: %s)", c.method, c.router) if !alreadyDefined { m[key] = struct{}{} } } } var uniqueAnnotations = []string{"@ID", "@Summary", "@Tags", "@Router"} func assertSingleAnnotations(t *testing.T, comments []SwaggerComment) { for _, comment := range comments { counters := map[string]int{} for _, line := range comment.raw { splitN := strings.SplitN(strings.TrimSpace(line.Text), " ", 3) if len(splitN) < 2 { continue // comment prefix without any content } if !strings.HasPrefix(splitN[1], "@") { continue // not a swagger annotation } annotation := splitN[1] if _, ok := counters[annotation]; !ok { counters[annotation] = 0 } counters[annotation]++ } for _, annotation := range uniqueAnnotations { v := counters[annotation] assert.Equal(t, 1, v, "%s annotation for route %s must be defined only once", annotation, comment.router) } } } func findSwaggerCommentByMethodAndRoute(comments []SwaggerComment, method, route string) *SwaggerComment { for _, c := range comments { if c.method == method && c.router == route { return &c } } return nil } var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9-]+`) func assertConsistencyBetweenRouteIDAndSummary(t *testing.T, comment SwaggerComment) { exp := strings.ToLower(comment.summary) exp = strings.ReplaceAll(exp, " ", "-") exp = nonAlphanumericRegex.ReplaceAllString(exp, "") assert.Equal(t, exp, comment.id, "Router ID must match summary") } func assertSuccessOrFailureDefined(t *testing.T, comment SwaggerComment) { assert.True(t, len(comment.successes) > 0 || len(comment.failures) > 0, "At least one @Success or @Failure annotation must be defined") } func assertRequiredAnnotations(t *testing.T, comment SwaggerComment) { assert.NotEmpty(t, comment.id, "@ID must be defined") assert.NotEmpty(t, comment.summary, "@Summary must be defined") assert.NotEmpty(t, comment.tags, "@Tags must be defined") assert.NotEmpty(t, comment.router, "@Router must be defined") } func assertGoCommentFirst(t *testing.T, comment SwaggerComment) { var inSwaggerBlock bool for _, line := range comment.raw { text := strings.TrimSpace(line.Text) if inSwaggerBlock { if !strings.HasPrefix(text, "// @") && !strings.HasPrefix(text, "// nolint:") { assert.Fail(t, "Go function comment must be placed before swagger comments") return } } if strings.HasPrefix(text, "// @Summary") { inSwaggerBlock = true } } } var urlParameterRegexp = regexp.MustCompile(`{[^{}]*}`) func assertPathParametersDefined(t *testing.T, comment SwaggerComment) { matches := urlParameterRegexp.FindAllString(comment.router, -1) if matches == nil { return // router does not require any parameters } for _, m := range matches { var matched bool for _, p := range comment.parameters { if p.kind == "path" && "{"+p.name+"}" == m { matched = true break } } if !matched { assert.Failf(t, "Missing @Param annotation", "Path parameter: %s", m) } } } func assertSecurityDefined(t *testing.T, comment SwaggerComment) { if comment.router == "/updatecheck" || comment.router == "/buildinfo" || comment.router == "/" || comment.router == "/users/login" { return // endpoints do not require authorization } assert.Equal(t, "CoderSessionToken", comment.security, "@Security must be equal CoderSessionToken") } func assertAccept(t *testing.T, comment SwaggerComment) { var hasRequestBody bool for _, c := range comment.parameters { if c.name == "request" && c.kind == "body" || c.name == "file" && c.kind == "formData" { hasRequestBody = true break } } var hasAccept bool if comment.accept != "" { hasAccept = true } if comment.method == "get" { assert.Empty(t, comment.accept, "GET route does not require the @Accept annotation") assert.False(t, hasRequestBody, "GET route does not require the request body") } else { assert.False(t, hasRequestBody && !hasAccept, "Route with the request body requires the @Accept annotation") assert.False(t, !hasRequestBody && hasAccept, "Route with @Accept annotation requires the request body or file formData parameter") } } var allowedProduceTypes = []string{"json", "text/event-stream", "text/html"} func assertProduce(t *testing.T, comment SwaggerComment) { var hasResponseModel bool for _, r := range comment.successes { if r.model != "" { hasResponseModel = true break } } if hasResponseModel { assert.True(t, comment.produce != "", "Route must have @Produce annotation as it responds with a model structure") assert.Contains(t, allowedProduceTypes, comment.produce, "@Produce value is limited to specific types: %s", strings.Join(allowedProduceTypes, ",")) } else { if (comment.router == "/workspaceagents/me/app-health" && comment.method == "post") || (comment.router == "/workspaceagents/me/startup" && comment.method == "post") || (comment.router == "/workspaceagents/me/startup/logs" && comment.method == "patch") || (comment.router == "/licenses/{id}" && comment.method == "delete") || (comment.router == "/debug/coordinator" && comment.method == "get") { return // Exception: HTTP 200 is returned without response entity } assert.Truef(t, comment.produce == "", "Response model is undefined, so we can't predict the content type: %v", comment) } }