├── .github └── workflows │ └── ci.yml ├── LICENSE ├── builder.go ├── docgen.go ├── docgen_test.go ├── funcinfo.go ├── go.mod ├── go.sum ├── markdown.go ├── raml ├── raml.go └── raml_test.go └── util.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | env: 6 | GOPATH: ${{ github.workspace }} 7 | GO111MODULE: off 8 | 9 | defaults: 10 | run: 11 | working-directory: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} 12 | 13 | strategy: 14 | matrix: 15 | go-version: [1.14.x, 1.15.x, 1.16.x] 16 | os: [ubuntu-latest, macos-latest, windows-latest] 17 | 18 | runs-on: ${{ matrix.os }} 19 | 20 | steps: 21 | - name: Install Go 22 | uses: actions/setup-go@v2 23 | with: 24 | go-version: ${{ matrix.go-version }} 25 | - name: Checkout code 26 | uses: actions/checkout@v2 27 | with: 28 | path: ${{ env.GOPATH }}/src/github.com/${{ github.repository }} 29 | - name: Test 30 | run: | 31 | go get -d -t ./... 32 | go test -v ./... 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-Present https://github.com/go-chi authors 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /builder.go: -------------------------------------------------------------------------------- 1 | package docgen 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/go-chi/chi/v5" 11 | ) 12 | 13 | func BuildDoc(r chi.Routes) (Doc, error) { 14 | d := Doc{} 15 | 16 | goPath := getGoPath() 17 | if goPath == "" { 18 | return d, errors.New("docgen: unable to determine your $GOPATH") 19 | } 20 | 21 | // Walk and generate the router docs 22 | d.Router = buildDocRouter(r) 23 | return d, nil 24 | } 25 | 26 | func buildDocRouter(r chi.Routes) DocRouter { 27 | rts := r 28 | dr := DocRouter{Middlewares: []DocMiddleware{}} 29 | drts := DocRoutes{} 30 | dr.Routes = drts 31 | 32 | for _, mw := range rts.Middlewares() { 33 | dmw := DocMiddleware{ 34 | FuncInfo: buildFuncInfo(mw), 35 | } 36 | dr.Middlewares = append(dr.Middlewares, dmw) 37 | } 38 | 39 | for _, rt := range rts.Routes() { 40 | drt := DocRoute{Pattern: rt.Pattern, Handlers: DocHandlers{}} 41 | 42 | if rt.SubRoutes != nil { 43 | subRoutes := rt.SubRoutes 44 | subDrts := buildDocRouter(subRoutes) 45 | drt.Router = &subDrts 46 | 47 | } else { 48 | hall := rt.Handlers["*"] 49 | for method, h := range rt.Handlers { 50 | if method != "*" && hall != nil && fmt.Sprintf("%v", hall) == fmt.Sprintf("%v", h) { 51 | continue 52 | } 53 | 54 | dh := DocHandler{Method: method, Middlewares: []DocMiddleware{}} 55 | 56 | var endpoint http.Handler 57 | chain, _ := h.(*chi.ChainHandler) 58 | 59 | if chain != nil { 60 | for _, mw := range chain.Middlewares { 61 | dh.Middlewares = append(dh.Middlewares, DocMiddleware{ 62 | FuncInfo: buildFuncInfo(mw), 63 | }) 64 | } 65 | endpoint = chain.Endpoint 66 | } else { 67 | endpoint = h 68 | } 69 | 70 | dh.FuncInfo = buildFuncInfo(endpoint) 71 | 72 | drt.Handlers[method] = dh 73 | } 74 | } 75 | 76 | drts[rt.Pattern] = drt 77 | } 78 | 79 | return dr 80 | } 81 | 82 | func buildFuncInfo(i interface{}) FuncInfo { 83 | fi := FuncInfo{} 84 | frame := getCallerFrame(i) 85 | goPathSrc := filepath.Join(getGoPath(), "src") 86 | 87 | if frame == nil { 88 | fi.Unresolvable = true 89 | return fi 90 | } 91 | 92 | pkgName := getPkgName(frame.File) 93 | if pkgName == "chi" { 94 | fi.Unresolvable = true 95 | } 96 | funcPath := frame.Func.Name() 97 | 98 | idx := strings.Index(funcPath, "/"+pkgName) 99 | if idx > 0 { 100 | fi.Pkg = funcPath[:idx+1+len(pkgName)] 101 | fi.Func = funcPath[idx+2+len(pkgName):] 102 | } else { 103 | fi.Func = funcPath 104 | } 105 | 106 | if strings.Index(fi.Func, ".func") > 0 { 107 | fi.Anonymous = true 108 | } 109 | 110 | fi.File = frame.File 111 | fi.Line = frame.Line 112 | if filepath.HasPrefix(fi.File, goPathSrc) { 113 | fi.File = fi.File[len(goPathSrc)+1:] 114 | } 115 | 116 | // Check if file info is unresolvable 117 | if !strings.Contains(funcPath, pkgName) { 118 | fi.Unresolvable = true 119 | } 120 | 121 | if !fi.Unresolvable { 122 | fi.Comment = getFuncComment(frame.File, frame.Line) 123 | } 124 | 125 | return fi 126 | } 127 | -------------------------------------------------------------------------------- /docgen.go: -------------------------------------------------------------------------------- 1 | package docgen 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/go-chi/chi/v5" 8 | ) 9 | 10 | type Doc struct { 11 | Router DocRouter `json:"router"` 12 | } 13 | 14 | type DocRouter struct { 15 | Middlewares []DocMiddleware `json:"middlewares"` 16 | Routes DocRoutes `json:"routes"` 17 | } 18 | 19 | type DocMiddleware struct { 20 | FuncInfo 21 | } 22 | 23 | type DocRoute struct { 24 | Pattern string `json:"-"` 25 | Handlers DocHandlers `json:"handlers,omitempty"` 26 | Router *DocRouter `json:"router,omitempty"` 27 | } 28 | 29 | type DocRoutes map[string]DocRoute // Pattern : DocRoute 30 | 31 | type DocHandler struct { 32 | Middlewares []DocMiddleware `json:"middlewares"` 33 | Method string `json:"method"` 34 | FuncInfo 35 | } 36 | 37 | type DocHandlers map[string]DocHandler // Method : DocHandler 38 | 39 | func PrintRoutes(r chi.Routes) { 40 | var printRoutes func(parentPattern string, r chi.Routes) 41 | printRoutes = func(parentPattern string, r chi.Routes) { 42 | rts := r.Routes() 43 | for _, rt := range rts { 44 | if rt.SubRoutes == nil { 45 | fmt.Println(parentPattern + rt.Pattern) 46 | } else { 47 | pat := rt.Pattern 48 | 49 | subRoutes := rt.SubRoutes 50 | printRoutes(parentPattern+pat, subRoutes) 51 | } 52 | } 53 | } 54 | printRoutes("", r) 55 | } 56 | 57 | func JSONRoutesDoc(r chi.Routes) string { 58 | doc, _ := BuildDoc(r) 59 | v, err := json.MarshalIndent(doc, "", " ") 60 | if err != nil { 61 | panic(err) 62 | } 63 | return string(v) 64 | } 65 | -------------------------------------------------------------------------------- /docgen_test.go: -------------------------------------------------------------------------------- 1 | package docgen_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/go-chi/chi/v5" 10 | "github.com/go-chi/docgen" 11 | ) 12 | 13 | // RequestID comment goes here. 14 | func RequestID(next http.Handler) http.Handler { 15 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | ctx := context.WithValue(r.Context(), "requestID", "1") 17 | next.ServeHTTP(w, r.WithContext(ctx)) 18 | }) 19 | } 20 | 21 | func hubIndexHandler(w http.ResponseWriter, r *http.Request) { 22 | ctx := r.Context() 23 | s := fmt.Sprintf("/hubs/%s reqid:%s session:%s", 24 | chi.URLParam(r, "hubID"), ctx.Value("requestID"), ctx.Value("session.user")) 25 | w.Write([]byte(s)) 26 | } 27 | 28 | // Generate docs for the MuxBig from chi/mux_test.go 29 | func TestMuxBig(t *testing.T) { 30 | // var sr1, sr2, sr3, sr4, sr5, sr6 *chi.Mux 31 | var r, sr3 *chi.Mux 32 | r = chi.NewRouter() 33 | r.Use(RequestID) 34 | 35 | // Some inline middleware, 1 36 | // We just love Go's ast tools 37 | r.Use(func(next http.Handler) http.Handler { 38 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 | next.ServeHTTP(w, r) 40 | }) 41 | }) 42 | r.Group(func(r chi.Router) { 43 | r.Use(func(next http.Handler) http.Handler { 44 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 45 | ctx := context.WithValue(r.Context(), "session.user", "anonymous") 46 | next.ServeHTTP(w, r.WithContext(ctx)) 47 | }) 48 | }) 49 | r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { 50 | w.Write([]byte("fav")) 51 | }) 52 | r.Get("/hubs/{hubID}/view", func(w http.ResponseWriter, r *http.Request) { 53 | ctx := r.Context() 54 | s := fmt.Sprintf("/hubs/%s/view reqid:%s session:%s", chi.URLParam(r, "hubID"), 55 | ctx.Value("requestID"), ctx.Value("session.user")) 56 | w.Write([]byte(s)) 57 | }) 58 | r.Get("/hubs/{hubID}/view/*", func(w http.ResponseWriter, r *http.Request) { 59 | ctx := r.Context() 60 | s := fmt.Sprintf("/hubs/%s/view/%s reqid:%s session:%s", chi.URLParamFromCtx(ctx, "hubID"), 61 | chi.URLParam(r, "*"), ctx.Value("requestID"), ctx.Value("session.user")) 62 | w.Write([]byte(s)) 63 | }) 64 | }) 65 | r.Group(func(r chi.Router) { 66 | r.Use(func(next http.Handler) http.Handler { 67 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 68 | ctx := context.WithValue(r.Context(), "session.user", "elvis") 69 | next.ServeHTTP(w, r.WithContext(ctx)) 70 | }) 71 | }) 72 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 73 | ctx := r.Context() 74 | s := fmt.Sprintf("/ reqid:%s session:%s", ctx.Value("requestID"), ctx.Value("session.user")) 75 | w.Write([]byte(s)) 76 | }) 77 | r.Get("/suggestions", func(w http.ResponseWriter, r *http.Request) { 78 | ctx := r.Context() 79 | s := fmt.Sprintf("/suggestions reqid:%s session:%s", ctx.Value("requestID"), ctx.Value("session.user")) 80 | w.Write([]byte(s)) 81 | }) 82 | 83 | r.Get("/woot/{wootID}/*", func(w http.ResponseWriter, r *http.Request) { 84 | s := fmt.Sprintf("/woot/%s/%s", chi.URLParam(r, "wootID"), chi.URLParam(r, "*")) 85 | w.Write([]byte(s)) 86 | }) 87 | 88 | r.Route("/hubs", func(r chi.Router) { 89 | _ = r.(*chi.Mux) // sr1 90 | r.Use(func(next http.Handler) http.Handler { 91 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 92 | next.ServeHTTP(w, r) 93 | }) 94 | }) 95 | r.Route("/{hubID}", func(r chi.Router) { 96 | _ = r.(*chi.Mux) // sr2 97 | r.Get("/", hubIndexHandler) 98 | r.Get("/touch", func(w http.ResponseWriter, r *http.Request) { 99 | ctx := r.Context() 100 | s := fmt.Sprintf("/hubs/%s/touch reqid:%s session:%s", chi.URLParam(r, "hubID"), 101 | ctx.Value("requestID"), ctx.Value("session.user")) 102 | w.Write([]byte(s)) 103 | }) 104 | 105 | sr3 = chi.NewRouter() 106 | sr3.Get("/", func(w http.ResponseWriter, r *http.Request) { 107 | ctx := r.Context() 108 | s := fmt.Sprintf("/hubs/%s/webhooks reqid:%s session:%s", chi.URLParam(r, "hubID"), 109 | ctx.Value("requestID"), ctx.Value("session.user")) 110 | w.Write([]byte(s)) 111 | }) 112 | sr3.Route("/{webhookID}", func(r chi.Router) { 113 | _ = r.(*chi.Mux) // sr4 114 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 115 | ctx := r.Context() 116 | s := fmt.Sprintf("/hubs/%s/webhooks/%s reqid:%s session:%s", chi.URLParam(r, "hubID"), 117 | chi.URLParam(r, "webhookID"), ctx.Value("requestID"), ctx.Value("session.user")) 118 | w.Write([]byte(s)) 119 | }) 120 | }) 121 | 122 | // TODO: /webooks is not coming up as a subrouter here... 123 | // we kind of want to wrap a Router... ? 124 | // perhaps add .Router() to the middleware inline thing.. 125 | // and use that always.. or, can detect in that method.. 126 | r.Mount("/webhooks", chi.Chain(func(next http.Handler) http.Handler { 127 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 128 | next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "hook", true))) 129 | }) 130 | }).Handler(sr3)) 131 | 132 | // HMMMM.. only let Mount() for just a Router..? 133 | // r.Mount("/webhooks", Use(...).Router(sr3)) 134 | // ... could this work even....? 135 | 136 | // HMMMMMMMMMMMMMMMMMMMMMMMM... 137 | // even if Mount() were to record all subhandlers mounted, we still couldn't get at the 138 | // routes 139 | 140 | r.Route("/posts", func(r chi.Router) { 141 | _ = r.(*chi.Mux) // sr5 142 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 143 | ctx := r.Context() 144 | s := fmt.Sprintf("/hubs/%s/posts reqid:%s session:%s", chi.URLParam(r, "hubID"), 145 | ctx.Value("requestID"), ctx.Value("session.user")) 146 | w.Write([]byte(s)) 147 | }) 148 | }) 149 | }) 150 | }) 151 | 152 | r.Route("/folders/", func(r chi.Router) { 153 | _ = r.(*chi.Mux) // sr6 154 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 155 | ctx := r.Context() 156 | s := fmt.Sprintf("/folders/ reqid:%s session:%s", 157 | ctx.Value("requestID"), ctx.Value("session.user")) 158 | w.Write([]byte(s)) 159 | }) 160 | r.Get("/public", func(w http.ResponseWriter, r *http.Request) { 161 | ctx := r.Context() 162 | s := fmt.Sprintf("/folders/public reqid:%s session:%s", 163 | ctx.Value("requestID"), ctx.Value("session.user")) 164 | w.Write([]byte(s)) 165 | }) 166 | r.Get("/in", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}).ServeHTTP) 167 | 168 | r.With(func(next http.Handler) http.Handler { 169 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 170 | next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "search", true))) 171 | }) 172 | }).Get("/search", func(w http.ResponseWriter, r *http.Request) { 173 | w.Write([]byte("searching..")) 174 | }) 175 | }) 176 | }) 177 | 178 | fmt.Println(docgen.JSONRoutesDoc(r)) 179 | 180 | // docgen.PrintRoutes(r) 181 | 182 | } 183 | -------------------------------------------------------------------------------- /funcinfo.go: -------------------------------------------------------------------------------- 1 | package docgen 2 | 3 | import ( 4 | "go/parser" 5 | "go/token" 6 | "path/filepath" 7 | "reflect" 8 | "runtime" 9 | "strings" 10 | ) 11 | 12 | type FuncInfo struct { 13 | Pkg string `json:"pkg"` 14 | Func string `json:"func"` 15 | Comment string `json:"comment"` 16 | File string `json:"file,omitempty"` 17 | Line int `json:"line,omitempty"` 18 | Anonymous bool `json:"anonymous,omitempty"` 19 | Unresolvable bool `json:"unresolvable,omitempty"` 20 | } 21 | 22 | func GetFuncInfo(i interface{}) FuncInfo { 23 | fi := FuncInfo{} 24 | frame := getCallerFrame(i) 25 | goPathSrc := filepath.Join(getGoPath(), "src") 26 | 27 | if frame == nil { 28 | fi.Unresolvable = true 29 | return fi 30 | } 31 | 32 | pkgName := getPkgName(frame.File) 33 | if pkgName == "chi" { 34 | fi.Unresolvable = true 35 | } 36 | funcPath := frame.Func.Name() 37 | 38 | idx := strings.Index(funcPath, "/"+pkgName) 39 | if idx > 0 { 40 | fi.Pkg = funcPath[:idx+1+len(pkgName)] 41 | fi.Func = funcPath[idx+2+len(pkgName):] 42 | } else { 43 | fi.Func = funcPath 44 | } 45 | 46 | if strings.Index(fi.Func, ".func") > 0 { 47 | fi.Anonymous = true 48 | } 49 | 50 | fi.File = frame.File 51 | fi.Line = frame.Line 52 | if filepath.HasPrefix(fi.File, goPathSrc) { 53 | fi.File = fi.File[len(goPathSrc)+1:] 54 | } 55 | 56 | // Check if file info is unresolvable 57 | if strings.Index(funcPath, pkgName) < 0 { 58 | fi.Unresolvable = true 59 | } 60 | 61 | if !fi.Unresolvable { 62 | fi.Comment = getFuncComment(frame.File, frame.Line) 63 | } 64 | 65 | return fi 66 | } 67 | 68 | func getCallerFrame(i interface{}) *runtime.Frame { 69 | value := reflect.ValueOf(i) 70 | if value.Kind() != reflect.Func { 71 | return nil 72 | } 73 | pc := value.Pointer() 74 | frames := runtime.CallersFrames([]uintptr{pc}) 75 | if frames == nil { 76 | return nil 77 | } 78 | frame, _ := frames.Next() 79 | if frame.Entry == 0 { 80 | return nil 81 | } 82 | return &frame 83 | } 84 | 85 | func getPkgName(file string) string { 86 | fset := token.NewFileSet() 87 | astFile, err := parser.ParseFile(fset, file, nil, parser.PackageClauseOnly) 88 | if err != nil { 89 | return "" 90 | } 91 | if astFile.Name == nil { 92 | return "" 93 | } 94 | return astFile.Name.Name 95 | } 96 | 97 | func getFuncComment(file string, line int) string { 98 | fset := token.NewFileSet() 99 | 100 | astFile, err := parser.ParseFile(fset, file, nil, parser.ParseComments) 101 | if err != nil { 102 | return "" 103 | } 104 | 105 | if len(astFile.Comments) == 0 { 106 | return "" 107 | } 108 | 109 | for _, cmt := range astFile.Comments { 110 | if fset.Position(cmt.End()).Line+1 == line { 111 | return cmt.Text() 112 | } 113 | } 114 | 115 | return "" 116 | } 117 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-chi/docgen 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.0.1 7 | github.com/go-chi/render v1.0.1 8 | gopkg.in/yaml.v2 v2.4.0 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-chi/chi/v5 v5.0.1 h1:ALxjCrTf1aflOlkhMnCUP86MubbWFrzB3gkRPReLpTo= 2 | github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 3 | github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8= 4 | github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= 5 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 6 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 7 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 8 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 9 | -------------------------------------------------------------------------------- /markdown.go: -------------------------------------------------------------------------------- 1 | package docgen 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/go-chi/chi/v5" 11 | ) 12 | 13 | type MarkdownDoc struct { 14 | Opts MarkdownOpts 15 | Router chi.Router 16 | Doc Doc 17 | Routes map[string]DocRouter // Pattern : DocRouter 18 | 19 | buf *bytes.Buffer 20 | } 21 | 22 | type MarkdownOpts struct { 23 | // ProjectPath is the base Go import path of the project 24 | ProjectPath string 25 | 26 | // Intro text included at the top of the generated markdown file. 27 | Intro string 28 | 29 | // ForceRelativeLinks to be relative even if they're not on github 30 | ForceRelativeLinks bool 31 | 32 | // URLMap allows specifying a map of package import paths to their link sources 33 | // Used for mapping vendored dependencies to their upstream sources 34 | // For example: 35 | // map[string]string{"github.com/my/package/vendor/go-chi/chi/": "https://github.com/go-chi/chi/blob/master/"} 36 | URLMap map[string]string 37 | } 38 | 39 | func MarkdownRoutesDoc(r chi.Router, opts MarkdownOpts) string { 40 | md := &MarkdownDoc{Router: r, Opts: opts} 41 | if err := md.Generate(); err != nil { 42 | return fmt.Sprintf("ERROR: %s\n", err.Error()) 43 | } 44 | return md.String() 45 | } 46 | 47 | func (md *MarkdownDoc) String() string { 48 | return md.buf.String() 49 | } 50 | 51 | func (md *MarkdownDoc) Generate() error { 52 | if md.Router == nil { 53 | return errors.New("docgen: router is nil") 54 | } 55 | 56 | doc, err := BuildDoc(md.Router) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | md.Doc = doc 62 | md.buf = &bytes.Buffer{} 63 | md.Routes = make(map[string]DocRouter) 64 | 65 | md.WriteIntro() 66 | md.WriteRoutes() 67 | 68 | return nil 69 | } 70 | 71 | func (md *MarkdownDoc) WriteIntro() { 72 | pkgName := md.Opts.ProjectPath 73 | md.buf.WriteString(fmt.Sprintf("# %s\n\n", pkgName)) 74 | 75 | intro := md.Opts.Intro 76 | md.buf.WriteString(fmt.Sprintf("%s\n\n", intro)) 77 | } 78 | 79 | func (md *MarkdownDoc) WriteRoutes() { 80 | md.buf.WriteString(fmt.Sprintf("## Routes\n\n")) 81 | 82 | var buildRoutesMap func(parentPattern string, ar, nr, dr *DocRouter) 83 | buildRoutesMap = func(parentPattern string, ar, nr, dr *DocRouter) { 84 | 85 | nr.Middlewares = append(nr.Middlewares, dr.Middlewares...) 86 | 87 | for pat, rt := range dr.Routes { 88 | pattern := parentPattern + pat 89 | 90 | nr.Routes = DocRoutes{} 91 | 92 | if rt.Router != nil { 93 | nnr := &DocRouter{} 94 | nr.Routes[pat] = DocRoute{ 95 | Pattern: pat, 96 | Handlers: rt.Handlers, 97 | Router: nnr, 98 | } 99 | buildRoutesMap(pattern, ar, nnr, rt.Router) 100 | 101 | } else if len(rt.Handlers) > 0 { 102 | nr.Routes[pat] = DocRoute{ 103 | Pattern: pat, 104 | Handlers: rt.Handlers, 105 | Router: nil, 106 | } 107 | 108 | // Remove the trailing slash if the handler is a subroute for "/" 109 | routeKey := pattern 110 | if pat == "/" && len(routeKey) > 1 { 111 | routeKey = routeKey[:len(routeKey)-1] 112 | } 113 | md.Routes[routeKey] = copyDocRouter(*ar) 114 | 115 | } else { 116 | panic("not possible") 117 | } 118 | } 119 | 120 | } 121 | 122 | // Build a route tree that consists of the full route pattern 123 | // and the part of the tree for just that specific route, stored 124 | // in routes map on the markdown struct. This is the structure we 125 | // are going to render to markdown. 126 | dr := md.Doc.Router 127 | ar := DocRouter{} 128 | buildRoutesMap("", &ar, &ar, &dr) 129 | 130 | // Generate the markdown to render the above structure 131 | var printRouter func(depth int, dr DocRouter) 132 | printRouter = func(depth int, dr DocRouter) { 133 | 134 | tabs := "" 135 | for i := 0; i < depth; i++ { 136 | tabs += "\t" 137 | } 138 | 139 | // Middlewares 140 | for _, mw := range dr.Middlewares { 141 | md.buf.WriteString(fmt.Sprintf("%s- [%s](%s)\n", tabs, mw.Func, md.githubSourceURL(mw.File, mw.Line))) 142 | } 143 | 144 | // Routes 145 | for _, rt := range dr.Routes { 146 | md.buf.WriteString(fmt.Sprintf("%s- **%s**\n", tabs, normalizer(rt.Pattern))) 147 | 148 | if rt.Router != nil { 149 | printRouter(depth+1, *rt.Router) 150 | } else { 151 | for meth, dh := range rt.Handlers { 152 | md.buf.WriteString(fmt.Sprintf("%s\t- _%s_\n", tabs, meth)) 153 | 154 | // Handler middlewares 155 | for _, mw := range dh.Middlewares { 156 | md.buf.WriteString(fmt.Sprintf("%s\t\t- [%s](%s)\n", tabs, mw.Func, md.githubSourceURL(mw.File, mw.Line))) 157 | } 158 | 159 | // Handler endpoint 160 | md.buf.WriteString(fmt.Sprintf("%s\t\t- [%s](%s)\n", tabs, dh.Func, md.githubSourceURL(dh.File, dh.Line))) 161 | } 162 | } 163 | } 164 | } 165 | 166 | routePaths := []string{} 167 | for pat := range md.Routes { 168 | routePaths = append(routePaths, pat) 169 | } 170 | sort.Strings(routePaths) 171 | 172 | for _, pat := range routePaths { 173 | dr := md.Routes[pat] 174 | md.buf.WriteString(fmt.Sprintf("
\n")) 175 | md.buf.WriteString(fmt.Sprintf("`%s`\n", normalizer(pat))) 176 | md.buf.WriteString(fmt.Sprintf("\n")) 177 | printRouter(0, dr) 178 | md.buf.WriteString(fmt.Sprintf("\n")) 179 | md.buf.WriteString(fmt.Sprintf("
\n")) 180 | } 181 | 182 | md.buf.WriteString(fmt.Sprintf("\n")) 183 | md.buf.WriteString(fmt.Sprintf("Total # of routes: %d\n", len(md.Routes))) 184 | 185 | // TODO: total number of handlers.. 186 | } 187 | 188 | func (md *MarkdownDoc) githubSourceURL(file string, line int) string { 189 | // Currently, we only automatically link to source for github projects 190 | if strings.Index(file, "github.com/") != 0 && !md.Opts.ForceRelativeLinks { 191 | return "" 192 | } 193 | if md.Opts.ProjectPath == "" { 194 | return "" 195 | } 196 | for pkg, url := range md.Opts.URLMap { 197 | if idx := strings.Index(file, pkg); idx >= 0 { 198 | pos := idx + len(pkg) 199 | url = strings.TrimRight(url, "/") 200 | filepath := strings.TrimLeft(file[pos:], "/") 201 | return fmt.Sprintf("%s/%s#L%d", url, filepath, line) 202 | } 203 | } 204 | if idx := strings.Index(file, md.Opts.ProjectPath); idx >= 0 { 205 | // relative 206 | pos := idx + len(md.Opts.ProjectPath) 207 | return fmt.Sprintf("%s#L%d", file[pos:], line) 208 | } 209 | // absolute 210 | return fmt.Sprintf("https://%s#L%d", file, line) 211 | } 212 | 213 | func normalizer(s string) string { 214 | if strings.Contains(s, "/*") { 215 | return strings.Replace(s, "/*", "", -1) 216 | } 217 | return s 218 | } 219 | -------------------------------------------------------------------------------- /raml/raml.go: -------------------------------------------------------------------------------- 1 | package raml 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | yaml "gopkg.in/yaml.v2" 9 | ) 10 | 11 | var header = `#%RAML 1.0 12 | --- 13 | ` 14 | 15 | type RAML struct { 16 | Title string `yaml:"title,omitempty"` 17 | BaseUri string `yaml:"baseUri,omitempty"` 18 | Protocols []string `yaml:"protocols,omitempty"` 19 | MediaType string `yaml:"mediaType,omitempty"` 20 | Version string `yaml:"version,omitempty"` 21 | Documentation []Documentation `yaml:"documentation,omitempty"` 22 | 23 | Resources `yaml:",inline"` 24 | } 25 | 26 | func (r *RAML) String() string { 27 | bytes, _ := yaml.Marshal(r) 28 | return fmt.Sprintf("%s%s", header, bytes) 29 | } 30 | 31 | type Documentation struct { 32 | Title string `yaml:"title"` 33 | Content string `yaml:"content"` 34 | } 35 | 36 | type Resources map[string]*Resource 37 | 38 | type Resource struct { 39 | DisplayName string `yaml:"displayName,omitempty"` 40 | Description string `yaml:"description,omitempty"` 41 | Responses Responses `yaml:"responses,omitempty"` 42 | Body Body `yaml:"body,omitempty"` 43 | Is []string `yaml:"is,omitempty"` 44 | Type string `yaml:"type,omitempty"` 45 | SecuredBy []string `yaml:"securedBy,omitempty"` 46 | UriParameters []string `yaml:"uirParameters,omitempty"` 47 | QueryParameters []string `yaml:"queryParameters,omitempty"` 48 | 49 | Resources `yaml:",inline"` 50 | } 51 | 52 | type Responses map[int]Response 53 | 54 | type Response struct { 55 | Body `yaml:"body,omitempty"` 56 | } 57 | 58 | type Body map[string]Example // Content-Type to Example 59 | 60 | type Example struct { 61 | Example string `yaml:"example,omitempty"` 62 | } 63 | 64 | func (r *RAML) Add(method string, route string, resource *Resource) error { 65 | if resource == nil { 66 | return errors.New("raml.Add(): resource can't be nil") 67 | } 68 | if r.Resources == nil { 69 | r.Resources = Resources{} 70 | } 71 | 72 | return r.Resources.upsert(method, route, resource) 73 | } 74 | 75 | func (r *RAML) AddUnder(parentRoute string, method string, route string, resource *Resource) error { 76 | if resource == nil { 77 | return errors.New("raml.Add(): resource can't be nil") 78 | } 79 | if r.Resources == nil { 80 | r.Resources = Resources{} 81 | } 82 | 83 | if parentRoute == "" || parentRoute == "/" { 84 | return errors.New("raml.AddUnderParent(): parentRoute can't be empty or '/'") 85 | } 86 | 87 | if !strings.HasPrefix(route, parentRoute) { 88 | return errors.New("raml.AddUnderParent(): parentRoute must be present in the route string") 89 | } 90 | 91 | route = strings.TrimPrefix(route, parentRoute) 92 | if route == "" { 93 | route = "/" 94 | } 95 | 96 | parentNode, found := r.Resources[parentRoute] 97 | if !found { 98 | parentNode = &Resource{ 99 | Resources: Resources{}, 100 | Responses: Responses{}, 101 | } 102 | r.Resources[parentRoute] = parentNode 103 | } 104 | 105 | return parentNode.Resources.upsert(method, route, resource) 106 | } 107 | 108 | // Find or create node tree from a given route and inject the resource. 109 | func (r Resources) upsert(method string, route string, resource *Resource) error { 110 | currentNode := r 111 | 112 | parts := strings.Split(route, "/") 113 | if len(parts) > 0 { 114 | last := len(parts) - 1 115 | 116 | // Upsert route of the resource. 117 | for _, part := range parts[:last] { 118 | if part == "" { 119 | continue 120 | } 121 | part = "/" + part 122 | 123 | node, found := currentNode[part] 124 | if !found { 125 | node = &Resource{ 126 | Resources: Resources{}, 127 | Responses: Responses{}, 128 | } 129 | 130 | currentNode[part] = node 131 | } 132 | currentNode = node.Resources 133 | } 134 | 135 | if parts[last] != "" { 136 | // Upsert resource into the very bottom of the node tree. 137 | part := "/" + parts[last] 138 | node, found := currentNode[part] 139 | if !found { 140 | node = &Resource{ 141 | Resources: Resources{}, 142 | Responses: Responses{}, 143 | } 144 | } 145 | currentNode[part] = node 146 | currentNode = node.Resources 147 | } 148 | } 149 | 150 | method = strings.ToLower(method) 151 | if _, found := currentNode[method]; found { 152 | return nil 153 | // return fmt.Errorf("duplicated method route: %v %v", method, route) 154 | } 155 | 156 | currentNode[method] = resource 157 | 158 | return nil 159 | } 160 | -------------------------------------------------------------------------------- /raml/raml_test.go: -------------------------------------------------------------------------------- 1 | package raml_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "math/rand" 8 | "net/http" 9 | "testing" 10 | 11 | "github.com/go-chi/chi/v5" 12 | "github.com/go-chi/chi/v5/middleware" 13 | "github.com/go-chi/docgen" 14 | "github.com/go-chi/docgen/raml" 15 | "github.com/go-chi/render" 16 | yaml "gopkg.in/yaml.v2" 17 | ) 18 | 19 | func TestWalkerRAML(t *testing.T) { 20 | r := Router() 21 | 22 | ramlDocs := &raml.RAML{ 23 | Title: "Big Mux", 24 | BaseUri: "https://bigmux.example.com", 25 | Version: "v1.0", 26 | MediaType: "application/json", 27 | } 28 | 29 | if err := chi.Walk(r, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { 30 | handlerInfo := docgen.GetFuncInfo(handler) 31 | resource := &raml.Resource{ 32 | Description: handlerInfo.Comment, 33 | } 34 | 35 | return ramlDocs.Add(method, route, resource) 36 | }); err != nil { 37 | t.Error(err) 38 | } 39 | 40 | _, err := yaml.Marshal(ramlDocs) 41 | if err != nil { 42 | t.Error(err) 43 | } 44 | } 45 | 46 | // Copy-pasted from _examples/raml. We can't simply import it, since it's main pkg. 47 | func Router() chi.Router { 48 | r := chi.NewRouter() 49 | 50 | r.Use(middleware.RequestID) 51 | r.Use(middleware.Logger) 52 | r.Use(middleware.Recoverer) 53 | 54 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 55 | w.Write([]byte("root.")) 56 | }) 57 | 58 | r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { 59 | w.Write([]byte("pong")) 60 | }) 61 | 62 | r.Get("/panic", func(w http.ResponseWriter, r *http.Request) { 63 | panic("test") 64 | }) 65 | 66 | // RESTy routes for "articles" resource 67 | r.Route("/articles", func(r chi.Router) { 68 | r.With(paginate).Get("/", ListArticles) 69 | r.Post("/", CreateArticle) // POST /articles 70 | r.Get("/search", SearchArticles) // GET /articles/search 71 | 72 | r.Route("/:articleID", func(r chi.Router) { 73 | r.Use(ArticleCtx) // Load the *Article on the request context 74 | r.Get("/", GetArticle) // GET /articles/123 75 | r.Put("/", UpdateArticle) // PUT /articles/123 76 | r.Delete("/", DeleteArticle) // DELETE /articles/123 77 | }) 78 | }) 79 | 80 | // Mount the admin sub-router, the same as a call to 81 | // Route("/admin", func(r chi.Router) { with routes here }) 82 | r.Mount("/admin", adminRouter()) 83 | 84 | return r 85 | } 86 | 87 | type Article struct { 88 | ID string `json:"id"` 89 | Title string `json:"title"` 90 | } 91 | 92 | // Article fixture data 93 | var articles = []*Article{ 94 | {ID: "1", Title: "Hi"}, 95 | {ID: "2", Title: "sup"}, 96 | } 97 | 98 | // ArticleCtx middleware is used to load an Article object from 99 | // the URL parameters passed through as the request. In case 100 | // the Article could not be found, we stop here and return a 404. 101 | func ArticleCtx(next http.Handler) http.Handler { 102 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 103 | articleID := chi.URLParam(r, "articleID") 104 | article, err := dbGetArticle(articleID) 105 | if err != nil { 106 | render.Status(r, http.StatusNotFound) 107 | render.JSON(w, r, http.StatusText(http.StatusNotFound)) 108 | return 109 | } 110 | ctx := context.WithValue(r.Context(), "article", article) 111 | next.ServeHTTP(w, r.WithContext(ctx)) 112 | }) 113 | } 114 | 115 | // Search Articles. 116 | // Searches the Articles data for a matching article. 117 | // It's just a stub, but you get the idea. 118 | func SearchArticles(w http.ResponseWriter, r *http.Request) { 119 | render.JSON(w, r, articles) 120 | } 121 | 122 | // List Articles. 123 | // Returns an array of Articles. 124 | func ListArticles(w http.ResponseWriter, r *http.Request) { 125 | render.JSON(w, r, articles) 126 | } 127 | 128 | // Create new Article. 129 | // Ppersists the posted Article and returns it 130 | // back to the client as an acknowledgement. 131 | func CreateArticle(w http.ResponseWriter, r *http.Request) { 132 | article := &Article{} 133 | 134 | render.JSON(w, r, article) 135 | } 136 | 137 | // Get a specific Article. 138 | func GetArticle(w http.ResponseWriter, r *http.Request) { 139 | article := r.Context().Value("article").(*Article) 140 | 141 | render.JSON(w, r, article) 142 | } 143 | 144 | // Update a specific Article. 145 | // Updates an existing Article in our persistent store. 146 | func UpdateArticle(w http.ResponseWriter, r *http.Request) { 147 | article := r.Context().Value("article").(*Article) 148 | 149 | render.JSON(w, r, article) 150 | } 151 | 152 | // Delete a specific Article. 153 | // Removes an existing Article from our persistent store. 154 | func DeleteArticle(w http.ResponseWriter, r *http.Request) { 155 | article := r.Context().Value("article").(*Article) 156 | 157 | render.JSON(w, r, article) 158 | } 159 | 160 | // A completely separate router for administrator routes 161 | func adminRouter() chi.Router { 162 | r := chi.NewRouter() 163 | r.Use(AdminOnly) 164 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 165 | w.Write([]byte("admin: index")) 166 | }) 167 | r.Get("/accounts", func(w http.ResponseWriter, r *http.Request) { 168 | w.Write([]byte("admin: list accounts..")) 169 | }) 170 | r.Get("/users/:userId", func(w http.ResponseWriter, r *http.Request) { 171 | w.Write([]byte(fmt.Sprintf("admin: view user id %v", chi.URLParam(r, "userId")))) 172 | }) 173 | return r 174 | } 175 | 176 | // AdminOnly middleware restricts access to just administrators. 177 | func AdminOnly(next http.Handler) http.Handler { 178 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 179 | isAdmin, ok := r.Context().Value("acl.admin").(bool) 180 | if !ok || !isAdmin { 181 | http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) 182 | return 183 | } 184 | next.ServeHTTP(w, r) 185 | }) 186 | } 187 | 188 | // paginate is a stub, but very possible to implement middleware logic 189 | // to handle the request params for handling a paginated request. 190 | func paginate(next http.Handler) http.Handler { 191 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 192 | // just a stub.. some ideas are to look at URL query params for something like 193 | // the page number, or the limit, and send a query cursor down the chain 194 | next.ServeHTTP(w, r) 195 | }) 196 | } 197 | 198 | //-- 199 | 200 | // Below are a bunch of helper functions that mock some kind of storage 201 | 202 | func dbNewArticle(article *Article) (string, error) { 203 | article.ID = fmt.Sprintf("%d", rand.Intn(100)+10) 204 | articles = append(articles, article) 205 | return article.ID, nil 206 | } 207 | 208 | func dbGetArticle(id string) (*Article, error) { 209 | for _, a := range articles { 210 | if a.ID == id { 211 | return a, nil 212 | } 213 | } 214 | return nil, errors.New("article not found.") 215 | } 216 | 217 | func dbRemoveArticle(id string) (*Article, error) { 218 | for i, a := range articles { 219 | if a.ID == id { 220 | articles = append((articles)[:i], (articles)[i+1:]...) 221 | return a, nil 222 | } 223 | } 224 | return nil, errors.New("article not found.") 225 | } 226 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package docgen 2 | 3 | import ( 4 | "go/build" 5 | "os" 6 | ) 7 | 8 | func copyDocRouter(dr DocRouter) DocRouter { 9 | var cloneRouter func(dr DocRouter) DocRouter 10 | var cloneRoutes func(drt DocRoutes) DocRoutes 11 | 12 | cloneRoutes = func(drts DocRoutes) DocRoutes { 13 | rts := DocRoutes{} 14 | 15 | for pat, drt := range drts { 16 | rt := DocRoute{Pattern: drt.Pattern} 17 | if len(drt.Handlers) > 0 { 18 | rt.Handlers = DocHandlers{} 19 | for meth, dh := range drt.Handlers { 20 | rt.Handlers[meth] = dh 21 | } 22 | } 23 | if drt.Router != nil { 24 | rr := cloneRouter(*drt.Router) 25 | rt.Router = &rr 26 | } 27 | rts[pat] = rt 28 | } 29 | 30 | return rts 31 | } 32 | 33 | cloneRouter = func(dr DocRouter) DocRouter { 34 | cr := DocRouter{} 35 | cr.Middlewares = make([]DocMiddleware, len(dr.Middlewares)) 36 | copy(cr.Middlewares, dr.Middlewares) 37 | cr.Routes = cloneRoutes(dr.Routes) 38 | return cr 39 | } 40 | 41 | return cloneRouter(dr) 42 | } 43 | 44 | func getGoPath() string { 45 | goPath := os.Getenv("GOPATH") 46 | if goPath == "" { 47 | goPath = build.Default.GOPATH 48 | } 49 | return goPath 50 | } 51 | --------------------------------------------------------------------------------