feat: Add documentation generation and serve functionality

- Implement documentation generation
- Replace chi router with gin for documentation server
- Fix issues with docs command execution
This commit is contained in:
2025-11-04 19:15:28 +03:00
parent 3fc545f067
commit 5a8b53b49d
10 changed files with 206 additions and 100 deletions

4
go.mod
View File

@@ -58,6 +58,9 @@ require (
github.com/redis/rueidis v1.0.66 // indirect github.com/redis/rueidis v1.0.66 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/swaggest/jsonschema-go v0.3.74 // indirect
github.com/swaggest/openapi-go v0.2.60 // indirect
github.com/swaggest/refl v1.3.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
@@ -72,6 +75,7 @@ require (
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect
) )

8
go.sum
View File

@@ -213,6 +213,12 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggest/jsonschema-go v0.3.74 h1:hkAZBK3RxNWU013kPqj0Q/GHGzYCCm9WcUTnfg2yPp0=
github.com/swaggest/jsonschema-go v0.3.74/go.mod h1:qp+Ym2DIXHlHzch3HKz50gPf2wJhKOrAB/VYqLS2oJU=
github.com/swaggest/openapi-go v0.2.60 h1:kglHH/WIfqAglfuWL4tu0LPakqNYySzklUWx06SjSKo=
github.com/swaggest/openapi-go v0.2.60/go.mod h1:jmFOuYdsWGtHU0BOuILlHZQJxLqHiAE6en+baE+QQUk=
github.com/swaggest/refl v1.3.1 h1:XGplEkYftR7p9cz1lsiwXMM2yzmOymTE9vneVVpaOh4=
github.com/swaggest/refl v1.3.1/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA=
github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts= github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts=
github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8= github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8=
github.com/testcontainers/testcontainers-go/modules/redis v0.39.0 h1:p54qELdCx4Gftkxzf44k9RJRRhaO/S5ehP9zo8SUTLM= github.com/testcontainers/testcontainers-go/modules/redis v0.39.0 h1:p54qELdCx4Gftkxzf44k9RJRRhaO/S5ehP9zo8SUTLM=
@@ -280,6 +286,8 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

24
html/redoc.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Documentation of OstiweStatus API</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>
<div id="redoc-container"></div>
<script>
Redoc.init('/static-doc', {
"showExtensions": true
}, document.getElementById('redoc-container'))
</script>
</body>
</html>

142
main.go
View File

@@ -1,20 +1,34 @@
package main package main
import ( import (
"embed"
"fmt"
"net/http"
"os"
"git.ostiwe.com/ostiwe-com/status/migration" "git.ostiwe.com/ostiwe-com/status/migration"
appLog "git.ostiwe.com/ostiwe-com/status/modules/log" appLog "git.ostiwe.com/ostiwe-com/status/modules/log"
"git.ostiwe.com/ostiwe-com/status/pkg/args" "git.ostiwe.com/ostiwe-com/status/pkg/args"
"git.ostiwe.com/ostiwe-com/status/router"
"git.ostiwe.com/ostiwe-com/status/server" "git.ostiwe.com/ostiwe-com/status/server"
_ "git.ostiwe.com/ostiwe-com/status/settings" "git.ostiwe.com/ostiwe-com/status/settings"
"github.com/alexflint/go-arg" "github.com/alexflint/go-arg"
"github.com/gin-gonic/gin"
) )
//go:embed html
var htmlFolder embed.FS
var appArgs args.AppArgs var appArgs args.AppArgs
func main() { func main() {
arg.MustParse(&appArgs) arg.MustParse(&appArgs)
defer appLog.Global.Get(appLog.SYSTEM).Debug("Exit from application")
if appArgs.Migration != nil && appArgs.Migration.Create != nil { if appArgs.Migration != nil && appArgs.Migration.Create != nil {
settings.Init()
if err := migration.CreateMigration(appArgs.Migration.Create.Name); err != nil { if err := migration.CreateMigration(appArgs.Migration.Create.Name); err != nil {
panic(err) panic(err)
} }
@@ -23,35 +37,111 @@ func main() {
} }
if appArgs.Server != nil { if appArgs.Server != nil {
migration.RunMigration() settings.Init()
migration.RunMigration()
server.Run(appArgs.Server) server.Run(appArgs.Server)
return return
} }
// TODO: Rewrite to use gin router, instead of chi router // TODO: Decompose document generation logic into separate methods
// if appArgs.ServerDocumentation != nil { // Current code block handles both generation and serving logic - should be separated
// appLog.Global.Get(appLog.SYSTEM).Info("Collect documentation") if appArgs.Docs == nil {
// return
// docs := router.Documentate() }
// if !appArgs.ServerDocumentation.Plain {
// chiRouter := chi.NewRouter()
//
// err := docs.SetupRoutes(chiRouter, docs)
// if err != nil {
// appLog.Global.Get(appLog.SYSTEM).Error(fmt.Sprintf("Setup docs routes error: %v", err))
// return
// }
//
// appLog.Global.Get(appLog.SYSTEM).Info(fmt.Sprintf("Start documentation server on port: %s", appArgs.ServerDocumentation.Port))
// err = http.ListenAndServe(fmt.Sprintf(":%s", appArgs.ServerDocumentation.Port), chiRouter)
// if err != nil {
// appLog.Global.Get(appLog.SYSTEM).Error(fmt.Sprintf("Startup server error: %v", err))
// }
//
// return
// }
// }
appLog.Global.Get(appLog.SYSTEM).Info("Exit from application") if appArgs.Docs.Generate != nil {
documentate, err := router.Documentate()
if err != nil {
appLog.Global.Get(appLog.SYSTEM).Error(err)
return
}
var file []byte
switch appArgs.Docs.Generate.Format {
case "json":
file, err = documentate.MarshalJSON()
case "yaml":
file, err = documentate.MarshalYAML()
}
if err != nil {
appLog.Global.Get(appLog.SYSTEM).Error(err)
return
}
if appArgs.Docs.Generate.Out == "stdout" {
_, err = os.Stdout.Write(file)
if err != nil {
appLog.Global.Get(appLog.SYSTEM).Error(err)
return
}
return
}
err = os.WriteFile(appArgs.Docs.Generate.Out, file, os.ModeAppend)
if err != nil {
appLog.Global.Get(appLog.SYSTEM).Error(err)
return
}
return
}
if appArgs.Docs.Serve != nil {
documentate, err := router.Documentate()
if err != nil {
appLog.Global.Get(appLog.SYSTEM).Error(err)
return
}
docsJson, err := documentate.MarshalJSON()
if err != nil {
appLog.Global.Get(appLog.SYSTEM).Error(err)
return
}
html, err := htmlFolder.ReadFile("html/redoc.html")
if err != nil {
appLog.Global.Get(appLog.SYSTEM).Error(err)
return
}
g := gin.New()
g.Handle("GET", "/static-doc", func(c *gin.Context) {
c.Writer.Header().Add("Content-type", "application/json")
_, err = c.Writer.Write(docsJson)
if err != nil {
c.Writer.WriteHeader(http.StatusInternalServerError)
return
}
})
g.Handle("GET", "/docs/index.html", func(c *gin.Context) {
c.Writer.Header().Add("Content-Type", "text/html")
_, err = c.Writer.Write(html)
if err != nil {
c.Writer.WriteHeader(http.StatusInternalServerError)
return
}
})
if err = g.Run(fmt.Sprintf(":%s", appArgs.Docs.Serve.Port)); err != nil {
appLog.Global.Get(appLog.SYSTEM).Error(err)
return
}
return
}
} }

View File

@@ -21,7 +21,7 @@ var (
AuthMiddleware *ginJwt.GinJWTMiddleware AuthMiddleware *ginJwt.GinJWTMiddleware
) )
func init() { func Init() {
jwtPublicKeyPath := os.Getenv("JWT_SIGN_PUBLIC_KEY_PATH") jwtPublicKeyPath := os.Getenv("JWT_SIGN_PUBLIC_KEY_PATH")
if !strings.HasPrefix(jwtPublicKeyPath, "/") { if !strings.HasPrefix(jwtPublicKeyPath, "/") {
jwtPublicKeyPath = settings.WorkingDir + "/" + jwtPublicKeyPath jwtPublicKeyPath = settings.WorkingDir + "/" + jwtPublicKeyPath

View File

@@ -3,7 +3,7 @@ package args
import "git.ostiwe.com/ostiwe-com/status/version" import "git.ostiwe.com/ostiwe-com/status/version"
type ServerCmd struct { type ServerCmd struct {
Port string `arg:"-p,--port" help:"Port to listen on" default:"8080"` Port string `arg:"-p,--port,env:APP_PORT" help:"Port to listen on" default:"8080"`
} }
type MigrationCreate struct { type MigrationCreate struct {
@@ -15,15 +15,23 @@ type Migration struct {
} }
type ServerDocumentationCmd struct { type ServerDocumentationCmd struct {
Port string `arg:"-p,--port" help:"Port to listen on" default:"8081"` Port string `arg:"-p,--port,env:APP_PORT_DOCS" help:"Port to listen on" default:"8081"`
Plain bool `arg:"--plain" help:"Enable plain text output" default:"true"` }
PlainFormat string `arg:"--plain-format" help:"Set format for output (json, yaml)" default:"yaml"`
type GenerateDocumentationCmd struct {
Format string `arg:"--format,-f" help:"Set output format (json, yaml)" default:"yaml"`
Out string `arg:"--out,-o" help:"Output file name (or stdout)" default:"stdout"`
}
type DocsCmd struct {
Serve *ServerDocumentationCmd `arg:"subcommand:serve" help:"Generate and serve the documentation server"`
Generate *GenerateDocumentationCmd `arg:"subcommand:generate" help:"Generate documentation to file"`
} }
type AppArgs struct { type AppArgs struct {
Server *ServerCmd `arg:"subcommand:server" help:"Start the api server"` Server *ServerCmd `arg:"subcommand:server" help:"Start the api server"`
ServerDocumentation *ServerDocumentationCmd `arg:"subcommand:server-docs" help:"Generate documentation for api server and start documentation server"` Docs *DocsCmd `arg:"subcommand:docs" help:"Generate documentation to file or run documentation server"`
Migration *Migration `arg:"subcommand:migration" help:"Migration utils"` Migration *Migration `arg:"subcommand:migration" help:"Migration utils"`
} }
func (AppArgs) Version() string { func (AppArgs) Version() string {

View File

@@ -2,7 +2,8 @@ package controller
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-andiamo/chioas" "github.com/swaggest/openapi-go"
"github.com/swaggest/openapi-go/openapi3"
) )
// SecuredController - means, controller has middlewares // SecuredController - means, controller has middlewares
@@ -12,7 +13,7 @@ type SecuredController interface {
} }
type DocumentateController interface { type DocumentateController interface {
Documentate() (*chioas.Paths, *chioas.Components) Documentate(r *openapi3.Reflector) openapi.OperationContext
} }
type Controller interface { type Controller interface {

View File

@@ -6,7 +6,8 @@ import (
"git.ostiwe.com/ostiwe-com/status/modules/log" "git.ostiwe.com/ostiwe-com/status/modules/log"
"git.ostiwe.com/ostiwe-com/status/router/controller" "git.ostiwe.com/ostiwe-com/status/router/controller"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-andiamo/chioas" "github.com/swaggest/openapi-go"
"github.com/swaggest/openapi-go/openapi3"
) )
type Controller struct { type Controller struct {
@@ -34,29 +35,19 @@ func (c *Controller) Handler() gin.HandlerFunc {
} }
} }
func (c *Controller) Documentate() (*chioas.Paths, *chioas.Components) { func (c *Controller) Documentate(r *openapi3.Reflector) openapi.OperationContext {
return &chioas.Paths{ op, err := r.NewOperationContext(c.Method(), c.Path())
"/ping": chioas.Path{ if err != nil {
Methods: map[string]chioas.Method{ return nil
http.MethodGet: { }
Handler: c.Handler(),
Responses: chioas.Responses{
http.StatusOK: {
ContentType: "plain/text",
Schema: chioas.Schema{Type: "string"},
Examples: chioas.Examples{
{
Name: "200 OK", op.SetDescription("Route for check service is alive")
Value: "pong", op.SetSummary("Server ping")
},
}, op.AddRespStructure("pong", func(cu *openapi.ContentUnit) {
}, cu.ContentType = "plain/text"
}, cu.HTTPStatus = http.StatusOK
}, })
},
Tag: "service", return op
Comment: "Route for check service is alive",
},
}, nil
} }

View File

@@ -2,9 +2,9 @@ package router
import ( import (
"fmt" "fmt"
"maps"
"time" "time"
"git.ostiwe.com/ostiwe-com/status/modules/jwt"
"git.ostiwe.com/ostiwe-com/status/modules/log" "git.ostiwe.com/ostiwe-com/status/modules/log"
"git.ostiwe.com/ostiwe-com/status/router/controller" "git.ostiwe.com/ostiwe-com/status/router/controller"
"git.ostiwe.com/ostiwe-com/status/router/controller/auth" "git.ostiwe.com/ostiwe-com/status/router/controller/auth"
@@ -12,7 +12,7 @@ import (
"git.ostiwe.com/ostiwe-com/status/router/controller/service" "git.ostiwe.com/ostiwe-com/status/router/controller/service"
"git.ostiwe.com/ostiwe-com/status/version" "git.ostiwe.com/ostiwe-com/status/version"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-andiamo/chioas" "github.com/swaggest/openapi-go/openapi3"
) )
func getControllers() []controller.Controller { func getControllers() []controller.Controller {
@@ -27,6 +27,8 @@ func InitRoutes() *gin.Engine {
log.Global.Get(log.SERVER).Info("Setting up routers") log.Global.Get(log.SERVER).Info("Setting up routers")
startTime := time.Now() startTime := time.Now()
jwt.Init()
r := gin.New() r := gin.New()
r.Use( r.Use(
gin.Recovery(), gin.Recovery(),
@@ -61,27 +63,16 @@ func InitRoutes() *gin.Engine {
return r return r
} }
func Documentate() chioas.Definition { func Documentate() (*openapi3.Spec, error) {
ctrlList := getControllers() ctrlList := getControllers()
apiDoc := chioas.Definition{
AutoHeadMethods: true, reflector := &openapi3.Reflector{
DocOptions: chioas.DocOptions{ Spec: &openapi3.Spec{
ServeDocs: true, Openapi: "3.0.3",
HideHeadMethods: true, Info: openapi3.Info{
}, Title: "OstiweStatus API",
Info: chioas.Info{ Version: version.AppVersion(),
Version: version.AppVersion(), },
Title: "Status page API Documentation",
},
Paths: make(chioas.Paths),
Components: &chioas.Components{
Schemas: make(chioas.Schemas, 0),
Requests: make(chioas.CommonRequests),
Responses: make(chioas.CommonResponses),
Examples: make(chioas.Examples, 0),
Parameters: make(chioas.CommonParameters),
SecuritySchemes: make(chioas.SecuritySchemes, 0),
Extensions: make(chioas.Extensions),
}, },
} }
@@ -91,25 +82,12 @@ func Documentate() chioas.Definition {
continue continue
} }
documentatePaths, components := documentated.Documentate() err := reflector.AddOperation(documentated.Documentate(reflector))
if err != nil {
if documentatePaths != nil { return nil, err
for path, pathDoc := range *documentatePaths {
apiDoc.Paths[path] = pathDoc
}
} }
if components != nil {
apiDoc.Components.Schemas = append(apiDoc.Components.Schemas, components.Schemas...)
apiDoc.Components.Examples = append(apiDoc.Components.Examples, components.Examples...)
apiDoc.Components.SecuritySchemes = append(apiDoc.Components.SecuritySchemes, components.SecuritySchemes...)
maps.Copy(apiDoc.Components.Requests, components.Requests)
maps.Copy(apiDoc.Components.Responses, components.Responses)
maps.Copy(apiDoc.Components.Parameters, components.Parameters)
maps.Copy(apiDoc.Components.Extensions, components.Extensions)
}
} }
return apiDoc return reflector.Spec, nil
} }

View File

@@ -17,7 +17,9 @@ var (
func init() { func init() {
appLog.SetGlobalManager(appLog.NewManager()) appLog.SetGlobalManager(appLog.NewManager())
appLog.Global.Put(appLog.SYSTEM, logrus.New()) appLog.Global.Put(appLog.SYSTEM, logrus.New())
}
func Init() {
var err error var err error
WorkingDir, err = os.Getwd() WorkingDir, err = os.Getwd()