adds api routes to documentation
This commit is contained in:
parent
b36ccae974
commit
fb668425de
14 changed files with 1457 additions and 2 deletions
15
vendor/github.com/go-chi/docgen/.travis.yml
generated
vendored
Normal file
15
vendor/github.com/go-chi/docgen/.travis.yml
generated
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
language: go
|
||||
|
||||
go:
|
||||
- 1.7.x
|
||||
- 1.8.x
|
||||
- tip
|
||||
|
||||
install:
|
||||
- go get -u golang.org/x/tools/cmd/goimports
|
||||
|
||||
script:
|
||||
- go get -d -t ./...
|
||||
- go test ./...
|
||||
- >
|
||||
goimports -d -e ./ | grep '.*' && { echo; echo "Aborting due to non-empty goimports output."; exit 1; } || :
|
||||
20
vendor/github.com/go-chi/docgen/LICENSE
generated
vendored
Normal file
20
vendor/github.com/go-chi/docgen/LICENSE
generated
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
Copyright (c) 2016-Present https://github.com/go-chi authors
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
127
vendor/github.com/go-chi/docgen/builder.go
generated
vendored
Normal file
127
vendor/github.com/go-chi/docgen/builder.go
generated
vendored
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package docgen
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
func BuildDoc(r chi.Routes) (Doc, error) {
|
||||
d := Doc{}
|
||||
|
||||
goPath := os.Getenv("GOPATH")
|
||||
if goPath == "" {
|
||||
return d, errors.New("docgen: unable to determine your $GOPATH")
|
||||
}
|
||||
|
||||
// Walk and generate the router docs
|
||||
d.Router = buildDocRouter(r)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func buildDocRouter(r chi.Routes) DocRouter {
|
||||
rts := r
|
||||
dr := DocRouter{Middlewares: []DocMiddleware{}}
|
||||
drts := DocRoutes{}
|
||||
dr.Routes = drts
|
||||
|
||||
for _, mw := range rts.Middlewares() {
|
||||
dmw := DocMiddleware{
|
||||
FuncInfo: buildFuncInfo(mw),
|
||||
}
|
||||
dr.Middlewares = append(dr.Middlewares, dmw)
|
||||
}
|
||||
|
||||
for _, rt := range rts.Routes() {
|
||||
drt := DocRoute{Pattern: rt.Pattern, Handlers: DocHandlers{}}
|
||||
|
||||
if rt.SubRoutes != nil {
|
||||
subRoutes := rt.SubRoutes
|
||||
subDrts := buildDocRouter(subRoutes)
|
||||
drt.Router = &subDrts
|
||||
|
||||
} else {
|
||||
hall := rt.Handlers["*"]
|
||||
for method, h := range rt.Handlers {
|
||||
if method != "*" && hall != nil && fmt.Sprintf("%v", hall) == fmt.Sprintf("%v", h) {
|
||||
continue
|
||||
}
|
||||
|
||||
dh := DocHandler{Method: method, Middlewares: []DocMiddleware{}}
|
||||
|
||||
var endpoint http.Handler
|
||||
chain, _ := h.(*chi.ChainHandler)
|
||||
|
||||
if chain != nil {
|
||||
for _, mw := range chain.Middlewares {
|
||||
dh.Middlewares = append(dh.Middlewares, DocMiddleware{
|
||||
FuncInfo: buildFuncInfo(mw),
|
||||
})
|
||||
}
|
||||
endpoint = chain.Endpoint
|
||||
} else {
|
||||
endpoint = h
|
||||
}
|
||||
|
||||
dh.FuncInfo = buildFuncInfo(endpoint)
|
||||
|
||||
drt.Handlers[method] = dh
|
||||
}
|
||||
}
|
||||
|
||||
drts[rt.Pattern] = drt
|
||||
}
|
||||
|
||||
return dr
|
||||
}
|
||||
|
||||
func buildFuncInfo(i interface{}) FuncInfo {
|
||||
fi := FuncInfo{}
|
||||
frame := getCallerFrame(i)
|
||||
goPathSrc := filepath.Join(os.Getenv("GOPATH"), "src")
|
||||
|
||||
if frame == nil {
|
||||
fi.Unresolvable = true
|
||||
return fi
|
||||
}
|
||||
|
||||
pkgName := getPkgName(frame.File)
|
||||
if pkgName == "chi" {
|
||||
fi.Unresolvable = true
|
||||
}
|
||||
funcPath := frame.Func.Name()
|
||||
|
||||
idx := strings.Index(funcPath, "/"+pkgName)
|
||||
if idx > 0 {
|
||||
fi.Pkg = funcPath[:idx+1+len(pkgName)]
|
||||
fi.Func = funcPath[idx+2+len(pkgName):]
|
||||
} else {
|
||||
fi.Func = funcPath
|
||||
}
|
||||
|
||||
if strings.Index(fi.Func, ".func") > 0 {
|
||||
fi.Anonymous = true
|
||||
}
|
||||
|
||||
fi.File = frame.File
|
||||
fi.Line = frame.Line
|
||||
if filepath.HasPrefix(fi.File, goPathSrc) {
|
||||
fi.File = fi.File[len(goPathSrc)+1:]
|
||||
}
|
||||
|
||||
// Check if file info is unresolvable
|
||||
if !strings.Contains(funcPath, pkgName) {
|
||||
fi.Unresolvable = true
|
||||
}
|
||||
|
||||
if !fi.Unresolvable {
|
||||
fi.Comment = getFuncComment(frame.File, frame.Line)
|
||||
}
|
||||
|
||||
return fi
|
||||
}
|
||||
64
vendor/github.com/go-chi/docgen/docgen.go
generated
vendored
Normal file
64
vendor/github.com/go-chi/docgen/docgen.go
generated
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package docgen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
type Doc struct {
|
||||
Router DocRouter `json:"router"`
|
||||
}
|
||||
|
||||
type DocRouter struct {
|
||||
Middlewares []DocMiddleware `json:"middlewares"`
|
||||
Routes DocRoutes `json:"routes"`
|
||||
}
|
||||
|
||||
type DocMiddleware struct {
|
||||
FuncInfo
|
||||
}
|
||||
|
||||
type DocRoute struct {
|
||||
Pattern string `json:"-"`
|
||||
Handlers DocHandlers `json:"handlers,omitempty"`
|
||||
Router *DocRouter `json:"router,omitempty"`
|
||||
}
|
||||
|
||||
type DocRoutes map[string]DocRoute // Pattern : DocRoute
|
||||
|
||||
type DocHandler struct {
|
||||
Middlewares []DocMiddleware `json:"middlewares"`
|
||||
Method string `json:"method"`
|
||||
FuncInfo
|
||||
}
|
||||
|
||||
type DocHandlers map[string]DocHandler // Method : DocHandler
|
||||
|
||||
func PrintRoutes(r chi.Routes) {
|
||||
var printRoutes func(parentPattern string, r chi.Routes)
|
||||
printRoutes = func(parentPattern string, r chi.Routes) {
|
||||
rts := r.Routes()
|
||||
for _, rt := range rts {
|
||||
if rt.SubRoutes == nil {
|
||||
fmt.Println(parentPattern + rt.Pattern)
|
||||
} else {
|
||||
pat := rt.Pattern
|
||||
|
||||
subRoutes := rt.SubRoutes
|
||||
printRoutes(parentPattern+pat, subRoutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
printRoutes("", r)
|
||||
}
|
||||
|
||||
func JSONRoutesDoc(r chi.Routes) string {
|
||||
doc, _ := BuildDoc(r)
|
||||
v, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(v)
|
||||
}
|
||||
181
vendor/github.com/go-chi/docgen/docgen_test.go
generated
vendored
Normal file
181
vendor/github.com/go-chi/docgen/docgen_test.go
generated
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package docgen_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/docgen"
|
||||
)
|
||||
|
||||
// RequestID comment goes here.
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), "requestID", "1")
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func hubIndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := fmt.Sprintf("/hubs/%s reqid:%s session:%s",
|
||||
chi.URLParam(r, "hubID"), ctx.Value("requestID"), ctx.Value("session.user"))
|
||||
w.Write([]byte(s))
|
||||
}
|
||||
|
||||
// Generate docs for the MuxBig from chi/mux_test.go
|
||||
func TestMuxBig(t *testing.T) {
|
||||
var r, sr1, sr2, sr3, sr4, sr5, sr6 *chi.Mux
|
||||
r = chi.NewRouter()
|
||||
r.Use(RequestID)
|
||||
|
||||
// Some inline middleware, 1
|
||||
// We just love Go's ast tools
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), "session.user", "anonymous")
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
})
|
||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("fav"))
|
||||
})
|
||||
r.Get("/hubs/{hubID}/view", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := fmt.Sprintf("/hubs/%s/view reqid:%s session:%s", chi.URLParam(r, "hubID"),
|
||||
ctx.Value("requestID"), ctx.Value("session.user"))
|
||||
w.Write([]byte(s))
|
||||
})
|
||||
r.Get("/hubs/{hubID}/view/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := fmt.Sprintf("/hubs/%s/view/%s reqid:%s session:%s", chi.URLParamFromCtx(ctx, "hubID"),
|
||||
chi.URLParam(r, "*"), ctx.Value("requestID"), ctx.Value("session.user"))
|
||||
w.Write([]byte(s))
|
||||
})
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), "session.user", "elvis")
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
})
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := fmt.Sprintf("/ reqid:%s session:%s", ctx.Value("requestID"), ctx.Value("session.user"))
|
||||
w.Write([]byte(s))
|
||||
})
|
||||
r.Get("/suggestions", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := fmt.Sprintf("/suggestions reqid:%s session:%s", ctx.Value("requestID"), ctx.Value("session.user"))
|
||||
w.Write([]byte(s))
|
||||
})
|
||||
|
||||
r.Get("/woot/{wootID}/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := fmt.Sprintf("/woot/%s/%s", chi.URLParam(r, "wootID"), chi.URLParam(r, "*"))
|
||||
w.Write([]byte(s))
|
||||
})
|
||||
|
||||
r.Route("/hubs", func(r chi.Router) {
|
||||
sr1 = r.(*chi.Mux)
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
r.Route("/{hubID}", func(r chi.Router) {
|
||||
sr2 = r.(*chi.Mux)
|
||||
r.Get("/", hubIndexHandler)
|
||||
r.Get("/touch", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := fmt.Sprintf("/hubs/%s/touch reqid:%s session:%s", chi.URLParam(r, "hubID"),
|
||||
ctx.Value("requestID"), ctx.Value("session.user"))
|
||||
w.Write([]byte(s))
|
||||
})
|
||||
|
||||
sr3 = chi.NewRouter()
|
||||
sr3.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := fmt.Sprintf("/hubs/%s/webhooks reqid:%s session:%s", chi.URLParam(r, "hubID"),
|
||||
ctx.Value("requestID"), ctx.Value("session.user"))
|
||||
w.Write([]byte(s))
|
||||
})
|
||||
sr3.Route("/{webhookID}", func(r chi.Router) {
|
||||
sr4 = r.(*chi.Mux)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := fmt.Sprintf("/hubs/%s/webhooks/%s reqid:%s session:%s", chi.URLParam(r, "hubID"),
|
||||
chi.URLParam(r, "webhookID"), ctx.Value("requestID"), ctx.Value("session.user"))
|
||||
w.Write([]byte(s))
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: /webooks is not coming up as a subrouter here...
|
||||
// we kind of want to wrap a Router... ?
|
||||
// perhaps add .Router() to the middleware inline thing..
|
||||
// and use that always.. or, can detect in that method..
|
||||
r.Mount("/webhooks", chi.Chain(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "hook", true)))
|
||||
})
|
||||
}).Handler(sr3))
|
||||
|
||||
// HMMMM.. only let Mount() for just a Router..?
|
||||
// r.Mount("/webhooks", Use(...).Router(sr3))
|
||||
// ... could this work even....?
|
||||
|
||||
// HMMMMMMMMMMMMMMMMMMMMMMMM...
|
||||
// even if Mount() were to record all subhandlers mounted, we still couldn't get at the
|
||||
// routes
|
||||
|
||||
r.Route("/posts", func(r chi.Router) {
|
||||
sr5 = r.(*chi.Mux)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := fmt.Sprintf("/hubs/%s/posts reqid:%s session:%s", chi.URLParam(r, "hubID"),
|
||||
ctx.Value("requestID"), ctx.Value("session.user"))
|
||||
w.Write([]byte(s))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/folders/", func(r chi.Router) {
|
||||
sr6 = r.(*chi.Mux)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := fmt.Sprintf("/folders/ reqid:%s session:%s",
|
||||
ctx.Value("requestID"), ctx.Value("session.user"))
|
||||
w.Write([]byte(s))
|
||||
})
|
||||
r.Get("/public", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := fmt.Sprintf("/folders/public reqid:%s session:%s",
|
||||
ctx.Value("requestID"), ctx.Value("session.user"))
|
||||
w.Write([]byte(s))
|
||||
})
|
||||
r.Get("/in", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}).ServeHTTP)
|
||||
|
||||
r.With(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "search", true)))
|
||||
})
|
||||
}).Get("/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("searching.."))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
fmt.Println(docgen.JSONRoutesDoc(r))
|
||||
|
||||
// docgen.PrintRoutes(r)
|
||||
|
||||
}
|
||||
113
vendor/github.com/go-chi/docgen/funcinfo.go
generated
vendored
Normal file
113
vendor/github.com/go-chi/docgen/funcinfo.go
generated
vendored
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package docgen
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FuncInfo struct {
|
||||
Pkg string `json:"pkg"`
|
||||
Func string `json:"func"`
|
||||
Comment string `json:"comment"`
|
||||
File string `json:"file,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Anonymous bool `json:"anonymous,omitempty"`
|
||||
Unresolvable bool `json:"unresolvable,omitempty"`
|
||||
}
|
||||
|
||||
func GetFuncInfo(i interface{}) FuncInfo {
|
||||
fi := FuncInfo{}
|
||||
frame := getCallerFrame(i)
|
||||
goPathSrc := filepath.Join(os.Getenv("GOPATH"), "src")
|
||||
|
||||
if frame == nil {
|
||||
fi.Unresolvable = true
|
||||
return fi
|
||||
}
|
||||
|
||||
pkgName := getPkgName(frame.File)
|
||||
if pkgName == "chi" {
|
||||
fi.Unresolvable = true
|
||||
}
|
||||
funcPath := frame.Func.Name()
|
||||
|
||||
idx := strings.Index(funcPath, "/"+pkgName)
|
||||
if idx > 0 {
|
||||
fi.Pkg = funcPath[:idx+1+len(pkgName)]
|
||||
fi.Func = funcPath[idx+2+len(pkgName):]
|
||||
} else {
|
||||
fi.Func = funcPath
|
||||
}
|
||||
|
||||
if strings.Index(fi.Func, ".func") > 0 {
|
||||
fi.Anonymous = true
|
||||
}
|
||||
|
||||
fi.File = frame.File
|
||||
fi.Line = frame.Line
|
||||
if filepath.HasPrefix(fi.File, goPathSrc) {
|
||||
fi.File = fi.File[len(goPathSrc)+1:]
|
||||
}
|
||||
|
||||
// Check if file info is unresolvable
|
||||
if strings.Index(funcPath, pkgName) < 0 {
|
||||
fi.Unresolvable = true
|
||||
}
|
||||
|
||||
if !fi.Unresolvable {
|
||||
fi.Comment = getFuncComment(frame.File, frame.Line)
|
||||
}
|
||||
|
||||
return fi
|
||||
}
|
||||
|
||||
func getCallerFrame(i interface{}) *runtime.Frame {
|
||||
pc := reflect.ValueOf(i).Pointer()
|
||||
frames := runtime.CallersFrames([]uintptr{pc})
|
||||
if frames == nil {
|
||||
return nil
|
||||
}
|
||||
frame, _ := frames.Next()
|
||||
if frame.Entry == 0 {
|
||||
return nil
|
||||
}
|
||||
return &frame
|
||||
}
|
||||
|
||||
func getPkgName(file string) string {
|
||||
fset := token.NewFileSet()
|
||||
astFile, err := parser.ParseFile(fset, file, nil, parser.PackageClauseOnly)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if astFile.Name == nil {
|
||||
return ""
|
||||
}
|
||||
return astFile.Name.Name
|
||||
}
|
||||
|
||||
func getFuncComment(file string, line int) string {
|
||||
fset := token.NewFileSet()
|
||||
|
||||
astFile, err := parser.ParseFile(fset, file, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(astFile.Comments) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, cmt := range astFile.Comments {
|
||||
if fset.Position(cmt.End()).Line+1 == line {
|
||||
return cmt.Text()
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
211
vendor/github.com/go-chi/docgen/markdown.go
generated
vendored
Normal file
211
vendor/github.com/go-chi/docgen/markdown.go
generated
vendored
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
package docgen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
type MarkdownDoc struct {
|
||||
Opts MarkdownOpts
|
||||
Router chi.Router
|
||||
Doc Doc
|
||||
Routes map[string]DocRouter // Pattern : DocRouter
|
||||
|
||||
buf *bytes.Buffer
|
||||
}
|
||||
|
||||
type MarkdownOpts struct {
|
||||
// ProjectPath is the base Go import path of the project
|
||||
ProjectPath string
|
||||
|
||||
// Intro text included at the top of the generated markdown file.
|
||||
Intro string
|
||||
|
||||
// ForceRelativeLinks to be relative even if they're not on github
|
||||
ForceRelativeLinks bool
|
||||
|
||||
// URLMap allows specifying a map of package import paths to their link sources
|
||||
// Used for mapping vendored dependencies to their upstream sources
|
||||
// For example:
|
||||
// map[string]string{"github.com/my/package/vendor/go-chi/chi/": "https://github.com/go-chi/chi/blob/master/"}
|
||||
URLMap map[string]string
|
||||
}
|
||||
|
||||
func MarkdownRoutesDoc(r chi.Router, opts MarkdownOpts) string {
|
||||
md := &MarkdownDoc{Router: r, Opts: opts}
|
||||
if err := md.Generate(); err != nil {
|
||||
return fmt.Sprintf("ERROR: %s\n", err.Error())
|
||||
}
|
||||
return md.String()
|
||||
}
|
||||
|
||||
func (md *MarkdownDoc) String() string {
|
||||
return md.buf.String()
|
||||
}
|
||||
|
||||
func (md *MarkdownDoc) Generate() error {
|
||||
if md.Router == nil {
|
||||
return errors.New("docgen: router is nil")
|
||||
}
|
||||
|
||||
doc, err := BuildDoc(md.Router)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
md.Doc = doc
|
||||
md.buf = &bytes.Buffer{}
|
||||
md.Routes = make(map[string]DocRouter)
|
||||
|
||||
md.WriteIntro()
|
||||
md.WriteRoutes()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (md *MarkdownDoc) WriteIntro() {
|
||||
pkgName := md.Opts.ProjectPath
|
||||
md.buf.WriteString(fmt.Sprintf("# %s\n\n", pkgName))
|
||||
|
||||
intro := md.Opts.Intro
|
||||
md.buf.WriteString(fmt.Sprintf("%s\n\n", intro))
|
||||
}
|
||||
|
||||
func (md *MarkdownDoc) WriteRoutes() {
|
||||
md.buf.WriteString(fmt.Sprintf("## Routes\n\n"))
|
||||
|
||||
var buildRoutesMap func(parentPattern string, ar, nr, dr *DocRouter)
|
||||
buildRoutesMap = func(parentPattern string, ar, nr, dr *DocRouter) {
|
||||
|
||||
nr.Middlewares = append(nr.Middlewares, dr.Middlewares...)
|
||||
|
||||
for pat, rt := range dr.Routes {
|
||||
pattern := parentPattern + pat
|
||||
|
||||
nr.Routes = DocRoutes{}
|
||||
|
||||
if rt.Router != nil {
|
||||
nnr := &DocRouter{}
|
||||
nr.Routes[pat] = DocRoute{
|
||||
Pattern: pat,
|
||||
Handlers: rt.Handlers,
|
||||
Router: nnr,
|
||||
}
|
||||
buildRoutesMap(pattern, ar, nnr, rt.Router)
|
||||
|
||||
} else if len(rt.Handlers) > 0 {
|
||||
nr.Routes[pat] = DocRoute{
|
||||
Pattern: pat,
|
||||
Handlers: rt.Handlers,
|
||||
Router: nil,
|
||||
}
|
||||
|
||||
// Remove the trailing slash if the handler is a subroute for "/"
|
||||
routeKey := pattern
|
||||
if pat == "/" && len(routeKey) > 1 {
|
||||
routeKey = routeKey[:len(routeKey)-1]
|
||||
}
|
||||
md.Routes[routeKey] = copyDocRouter(*ar)
|
||||
|
||||
} else {
|
||||
panic("not possible")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Build a route tree that consists of the full route pattern
|
||||
// and the part of the tree for just that specific route, stored
|
||||
// in routes map on the markdown struct. This is the structure we
|
||||
// are going to render to markdown.
|
||||
dr := md.Doc.Router
|
||||
ar := DocRouter{}
|
||||
buildRoutesMap("", &ar, &ar, &dr)
|
||||
|
||||
// Generate the markdown to render the above structure
|
||||
var printRouter func(depth int, dr DocRouter)
|
||||
printRouter = func(depth int, dr DocRouter) {
|
||||
|
||||
tabs := ""
|
||||
for i := 0; i < depth; i++ {
|
||||
tabs += "\t"
|
||||
}
|
||||
|
||||
// Middlewares
|
||||
for _, mw := range dr.Middlewares {
|
||||
md.buf.WriteString(fmt.Sprintf("%s- [%s](%s)\n", tabs, mw.Func, md.githubSourceURL(mw.File, mw.Line)))
|
||||
}
|
||||
|
||||
// Routes
|
||||
for _, rt := range dr.Routes {
|
||||
md.buf.WriteString(fmt.Sprintf("%s- **%s**\n", tabs, rt.Pattern))
|
||||
|
||||
if rt.Router != nil {
|
||||
printRouter(depth+1, *rt.Router)
|
||||
} else {
|
||||
for meth, dh := range rt.Handlers {
|
||||
md.buf.WriteString(fmt.Sprintf("%s\t- _%s_\n", tabs, meth))
|
||||
|
||||
// Handler middlewares
|
||||
for _, mw := range dh.Middlewares {
|
||||
md.buf.WriteString(fmt.Sprintf("%s\t\t- [%s](%s)\n", tabs, mw.Func, md.githubSourceURL(mw.File, mw.Line)))
|
||||
}
|
||||
|
||||
// Handler endpoint
|
||||
md.buf.WriteString(fmt.Sprintf("%s\t\t- [%s](%s)\n", tabs, dh.Func, md.githubSourceURL(dh.File, dh.Line)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
routePaths := []string{}
|
||||
for pat := range md.Routes {
|
||||
routePaths = append(routePaths, pat)
|
||||
}
|
||||
sort.Strings(routePaths)
|
||||
|
||||
for _, pat := range routePaths {
|
||||
dr := md.Routes[pat]
|
||||
md.buf.WriteString(fmt.Sprintf("<details>\n"))
|
||||
md.buf.WriteString(fmt.Sprintf("<summary>`%s`</summary>\n", pat))
|
||||
md.buf.WriteString(fmt.Sprintf("\n"))
|
||||
printRouter(0, dr)
|
||||
md.buf.WriteString(fmt.Sprintf("\n"))
|
||||
md.buf.WriteString(fmt.Sprintf("</details>\n"))
|
||||
}
|
||||
|
||||
md.buf.WriteString(fmt.Sprintf("\n"))
|
||||
md.buf.WriteString(fmt.Sprintf("Total # of routes: %d\n", len(md.Routes)))
|
||||
|
||||
// TODO: total number of handlers..
|
||||
}
|
||||
|
||||
func (md *MarkdownDoc) githubSourceURL(file string, line int) string {
|
||||
// Currently, we only automatically link to source for github projects
|
||||
if strings.Index(file, "github.com/") != 0 && !md.Opts.ForceRelativeLinks {
|
||||
return ""
|
||||
}
|
||||
if md.Opts.ProjectPath == "" {
|
||||
return ""
|
||||
}
|
||||
for pkg, url := range md.Opts.URLMap {
|
||||
if idx := strings.Index(file, pkg); idx >= 0 {
|
||||
pos := idx + len(pkg)
|
||||
url = strings.TrimRight(url, "/")
|
||||
filepath := strings.TrimLeft(file[pos:], "/")
|
||||
return fmt.Sprintf("%s/%s#L%d", url, filepath, line)
|
||||
}
|
||||
}
|
||||
if idx := strings.Index(file, md.Opts.ProjectPath); idx >= 0 {
|
||||
// relative
|
||||
pos := idx + len(md.Opts.ProjectPath)
|
||||
return fmt.Sprintf("%s#L%d", file[pos:], line)
|
||||
}
|
||||
// absolute
|
||||
return fmt.Sprintf("https://%s#L%d", file, line)
|
||||
}
|
||||
159
vendor/github.com/go-chi/docgen/raml/raml.go
generated
vendored
Normal file
159
vendor/github.com/go-chi/docgen/raml/raml.go
generated
vendored
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package raml
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
var header = `#%RAML 1.0
|
||||
---
|
||||
`
|
||||
|
||||
type RAML struct {
|
||||
Title string `yaml:"title,omitempty"`
|
||||
BaseUri string `yaml:"baseUri,omitempty"`
|
||||
Protocols []string `yaml:"protocols,omitempty"`
|
||||
MediaType string `yaml:"mediaType,omitempty"`
|
||||
Version string `yaml:"version,omitempty"`
|
||||
Documentation []Documentation `yaml:"documentation,omitempty"`
|
||||
|
||||
Resources `yaml:",inline"`
|
||||
}
|
||||
|
||||
func (r *RAML) String() string {
|
||||
bytes, _ := yaml.Marshal(r)
|
||||
return fmt.Sprintf("%s%s", header, bytes)
|
||||
}
|
||||
|
||||
type Documentation struct {
|
||||
Title string `yaml:"title"`
|
||||
Content string `yaml:"content"`
|
||||
}
|
||||
|
||||
type Resources map[string]*Resource
|
||||
|
||||
type Resource struct {
|
||||
DisplayName string `yaml:"displayName,omitempty"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Responses Responses `yaml:"responses,omitempty"`
|
||||
Body Body `yaml:"body,omitempty"`
|
||||
Is []string `yaml:"is,omitempty"`
|
||||
Type string `yaml:"type,omitempty"`
|
||||
SecuredBy []string `yaml:"securedBy,omitempty"`
|
||||
UriParameters []string `yaml:"uirParameters,omitempty"`
|
||||
QueryParameters []string `yaml:"queryParameters,omitempty"`
|
||||
|
||||
Resources `yaml:",inline"`
|
||||
}
|
||||
|
||||
type Responses map[int]Response
|
||||
|
||||
type Response struct {
|
||||
Body `yaml:"body,omitempty"`
|
||||
}
|
||||
|
||||
type Body map[string]Example // Content-Type to Example
|
||||
|
||||
type Example struct {
|
||||
Example string `yaml:"example,omitempty"`
|
||||
}
|
||||
|
||||
func (r *RAML) Add(method string, route string, resource *Resource) error {
|
||||
if resource == nil {
|
||||
return errors.New("raml.Add(): resource can't be nil")
|
||||
}
|
||||
if r.Resources == nil {
|
||||
r.Resources = Resources{}
|
||||
}
|
||||
|
||||
return r.Resources.upsert(method, route, resource)
|
||||
}
|
||||
|
||||
func (r *RAML) AddUnder(parentRoute string, method string, route string, resource *Resource) error {
|
||||
if resource == nil {
|
||||
return errors.New("raml.Add(): resource can't be nil")
|
||||
}
|
||||
if r.Resources == nil {
|
||||
r.Resources = Resources{}
|
||||
}
|
||||
|
||||
if parentRoute == "" || parentRoute == "/" {
|
||||
return errors.New("raml.AddUnderParent(): parentRoute can't be empty or '/'")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(route, parentRoute) {
|
||||
return errors.New("raml.AddUnderParent(): parentRoute must be present in the route string")
|
||||
}
|
||||
|
||||
route = strings.TrimPrefix(route, parentRoute)
|
||||
if route == "" {
|
||||
route = "/"
|
||||
}
|
||||
|
||||
parentNode, found := r.Resources[parentRoute]
|
||||
if !found {
|
||||
parentNode = &Resource{
|
||||
Resources: Resources{},
|
||||
Responses: Responses{},
|
||||
}
|
||||
r.Resources[parentRoute] = parentNode
|
||||
}
|
||||
|
||||
return parentNode.Resources.upsert(method, route, resource)
|
||||
}
|
||||
|
||||
// Find or create node tree from a given route and inject the resource.
|
||||
func (r Resources) upsert(method string, route string, resource *Resource) error {
|
||||
currentNode := r
|
||||
|
||||
parts := strings.Split(route, "/")
|
||||
if len(parts) > 0 {
|
||||
last := len(parts) - 1
|
||||
|
||||
// Upsert route of the resource.
|
||||
for _, part := range parts[:last] {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
part = "/" + part
|
||||
|
||||
node, found := currentNode[part]
|
||||
if !found {
|
||||
node = &Resource{
|
||||
Resources: Resources{},
|
||||
Responses: Responses{},
|
||||
}
|
||||
|
||||
currentNode[part] = node
|
||||
}
|
||||
currentNode = node.Resources
|
||||
}
|
||||
|
||||
if parts[last] != "" {
|
||||
// Upsert resource into the very bottom of the node tree.
|
||||
part := "/" + parts[last]
|
||||
node, found := currentNode[part]
|
||||
if !found {
|
||||
node = &Resource{
|
||||
Resources: Resources{},
|
||||
Responses: Responses{},
|
||||
}
|
||||
}
|
||||
currentNode[part] = node
|
||||
currentNode = node.Resources
|
||||
}
|
||||
}
|
||||
|
||||
method = strings.ToLower(method)
|
||||
if _, found := currentNode[method]; found {
|
||||
return nil
|
||||
// return fmt.Errorf("duplicated method route: %v %v", method, route)
|
||||
}
|
||||
|
||||
currentNode[method] = resource
|
||||
|
||||
return nil
|
||||
}
|
||||
225
vendor/github.com/go-chi/docgen/raml/raml_test.go
generated
vendored
Normal file
225
vendor/github.com/go-chi/docgen/raml/raml_test.go
generated
vendored
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
package raml_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/docgen"
|
||||
"github.com/go-chi/docgen/raml"
|
||||
"github.com/go-chi/render"
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
func TestWalkerRAML(t *testing.T) {
|
||||
r := Router()
|
||||
|
||||
ramlDocs := &raml.RAML{
|
||||
Title: "Big Mux",
|
||||
BaseUri: "https://bigmux.example.com",
|
||||
Version: "v1.0",
|
||||
MediaType: "application/json",
|
||||
}
|
||||
|
||||
if err := chi.Walk(r, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
handlerInfo := docgen.GetFuncInfo(handler)
|
||||
resource := &raml.Resource{
|
||||
Description: handlerInfo.Comment,
|
||||
}
|
||||
|
||||
return ramlDocs.Add(method, route, resource)
|
||||
}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
_, err := yaml.Marshal(ramlDocs)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy-pasted from _examples/raml. We can't simply import it, since it's main pkg.
|
||||
func Router() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("root."))
|
||||
})
|
||||
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("pong"))
|
||||
})
|
||||
|
||||
r.Get("/panic", func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("test")
|
||||
})
|
||||
|
||||
// RESTy routes for "articles" resource
|
||||
r.Route("/articles", func(r chi.Router) {
|
||||
r.With(paginate).Get("/", ListArticles)
|
||||
r.Post("/", CreateArticle) // POST /articles
|
||||
r.Get("/search", SearchArticles) // GET /articles/search
|
||||
|
||||
r.Route("/:articleID", func(r chi.Router) {
|
||||
r.Use(ArticleCtx) // Load the *Article on the request context
|
||||
r.Get("/", GetArticle) // GET /articles/123
|
||||
r.Put("/", UpdateArticle) // PUT /articles/123
|
||||
r.Delete("/", DeleteArticle) // DELETE /articles/123
|
||||
})
|
||||
})
|
||||
|
||||
// Mount the admin sub-router, the same as a call to
|
||||
// Route("/admin", func(r chi.Router) { with routes here })
|
||||
r.Mount("/admin", adminRouter())
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type Article struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// Article fixture data
|
||||
var articles = []*Article{
|
||||
{ID: "1", Title: "Hi"},
|
||||
{ID: "2", Title: "sup"},
|
||||
}
|
||||
|
||||
// ArticleCtx middleware is used to load an Article object from
|
||||
// the URL parameters passed through as the request. In case
|
||||
// the Article could not be found, we stop here and return a 404.
|
||||
func ArticleCtx(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
articleID := chi.URLParam(r, "articleID")
|
||||
article, err := dbGetArticle(articleID)
|
||||
if err != nil {
|
||||
render.Status(r, http.StatusNotFound)
|
||||
render.JSON(w, r, http.StatusText(http.StatusNotFound))
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "article", article)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// Search Articles.
|
||||
// Searches the Articles data for a matching article.
|
||||
// It's just a stub, but you get the idea.
|
||||
func SearchArticles(w http.ResponseWriter, r *http.Request) {
|
||||
render.JSON(w, r, articles)
|
||||
}
|
||||
|
||||
// List Articles.
|
||||
// Returns an array of Articles.
|
||||
func ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
render.JSON(w, r, articles)
|
||||
}
|
||||
|
||||
// Create new Article.
|
||||
// Ppersists the posted Article and returns it
|
||||
// back to the client as an acknowledgement.
|
||||
func CreateArticle(w http.ResponseWriter, r *http.Request) {
|
||||
article := &Article{}
|
||||
|
||||
render.JSON(w, r, article)
|
||||
}
|
||||
|
||||
// Get a specific Article.
|
||||
func GetArticle(w http.ResponseWriter, r *http.Request) {
|
||||
article := r.Context().Value("article").(*Article)
|
||||
|
||||
render.JSON(w, r, article)
|
||||
}
|
||||
|
||||
// Update a specific Article.
|
||||
// Updates an existing Article in our persistent store.
|
||||
func UpdateArticle(w http.ResponseWriter, r *http.Request) {
|
||||
article := r.Context().Value("article").(*Article)
|
||||
|
||||
render.JSON(w, r, article)
|
||||
}
|
||||
|
||||
// Delete a specific Article.
|
||||
// Removes an existing Article from our persistent store.
|
||||
func DeleteArticle(w http.ResponseWriter, r *http.Request) {
|
||||
article := r.Context().Value("article").(*Article)
|
||||
|
||||
render.JSON(w, r, article)
|
||||
}
|
||||
|
||||
// A completely separate router for administrator routes
|
||||
func adminRouter() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Use(AdminOnly)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("admin: index"))
|
||||
})
|
||||
r.Get("/accounts", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("admin: list accounts.."))
|
||||
})
|
||||
r.Get("/users/:userId", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(fmt.Sprintf("admin: view user id %v", chi.URLParam(r, "userId"))))
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// AdminOnly middleware restricts access to just administrators.
|
||||
func AdminOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
isAdmin, ok := r.Context().Value("acl.admin").(bool)
|
||||
if !ok || !isAdmin {
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// paginate is a stub, but very possible to implement middleware logic
|
||||
// to handle the request params for handling a paginated request.
|
||||
func paginate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// just a stub.. some ideas are to look at URL query params for something like
|
||||
// the page number, or the limit, and send a query cursor down the chain
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
//--
|
||||
|
||||
// Below are a bunch of helper functions that mock some kind of storage
|
||||
|
||||
func dbNewArticle(article *Article) (string, error) {
|
||||
article.ID = fmt.Sprintf("%d", rand.Intn(100)+10)
|
||||
articles = append(articles, article)
|
||||
return article.ID, nil
|
||||
}
|
||||
|
||||
func dbGetArticle(id string) (*Article, error) {
|
||||
for _, a := range articles {
|
||||
if a.ID == id {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("article not found.")
|
||||
}
|
||||
|
||||
func dbRemoveArticle(id string) (*Article, error) {
|
||||
for i, a := range articles {
|
||||
if a.ID == id {
|
||||
articles = append((articles)[:i], (articles)[i+1:]...)
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("article not found.")
|
||||
}
|
||||
37
vendor/github.com/go-chi/docgen/util.go
generated
vendored
Normal file
37
vendor/github.com/go-chi/docgen/util.go
generated
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package docgen
|
||||
|
||||
func copyDocRouter(dr DocRouter) DocRouter {
|
||||
var cloneRouter func(dr DocRouter) DocRouter
|
||||
var cloneRoutes func(drt DocRoutes) DocRoutes
|
||||
|
||||
cloneRoutes = func(drts DocRoutes) DocRoutes {
|
||||
rts := DocRoutes{}
|
||||
|
||||
for pat, drt := range drts {
|
||||
rt := DocRoute{Pattern: drt.Pattern}
|
||||
if len(drt.Handlers) > 0 {
|
||||
rt.Handlers = DocHandlers{}
|
||||
for meth, dh := range drt.Handlers {
|
||||
rt.Handlers[meth] = dh
|
||||
}
|
||||
}
|
||||
if drt.Router != nil {
|
||||
rr := cloneRouter(*drt.Router)
|
||||
rt.Router = &rr
|
||||
}
|
||||
rts[pat] = rt
|
||||
}
|
||||
|
||||
return rts
|
||||
}
|
||||
|
||||
cloneRouter = func(dr DocRouter) DocRouter {
|
||||
cr := DocRouter{}
|
||||
cr.Middlewares = make([]DocMiddleware, len(dr.Middlewares))
|
||||
copy(cr.Middlewares, dr.Middlewares)
|
||||
cr.Routes = cloneRoutes(dr.Routes)
|
||||
return cr
|
||||
}
|
||||
|
||||
return cloneRouter(dr)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue