package api import ( "encoding/json" "fmt" "net/http" "sync" scalargo "github.com/bdpiprava/scalar-go" ) // OpenAPIInfo contains metadata about the API. type OpenAPIInfo struct { Title string `json:"title"` Description string `json:"description,omitempty"` Version string `json:"version"` } // OpenAPIServer describes a server endpoint. type OpenAPIServer struct { URL string `json:"url"` Description string `json:"description,omitempty"` } // OpenAPISpec represents a minimal OpenAPI 3.0 specification. type OpenAPISpec struct { OpenAPI string `json:"openapi"` Info OpenAPIInfo `json:"info"` Servers []OpenAPIServer `json:"servers,omitempty"` Paths map[string]map[string]interface{} `json:"paths"` Tags []OpenAPITag `json:"tags,omitempty"` mu sync.RWMutex } // OpenAPITag groups operations together. type OpenAPITag struct { Name string `json:"name"` Description string `json:"description,omitempty"` } // NewOpenAPISpec creates a new OpenAPI specification builder. func NewOpenAPISpec(title, version string) *OpenAPISpec { return &OpenAPISpec{ OpenAPI: "3.0.3", Info: OpenAPIInfo{ Title: title, Version: version, }, Paths: make(map[string]map[string]interface{}), } } // WithDescription sets the API description. func (s *OpenAPISpec) WithDescription(desc string) *OpenAPISpec { s.Info.Description = desc return s } // WithServer adds a server to the spec. func (s *OpenAPISpec) WithServer(url, description string) *OpenAPISpec { s.Servers = append(s.Servers, OpenAPIServer{ URL: url, Description: description, }) return s } // WithTag adds a tag for grouping operations. func (s *OpenAPISpec) WithTag(name, description string) *OpenAPISpec { s.Tags = append(s.Tags, OpenAPITag{ Name: name, Description: description, }) return s } // AddPath adds an operation to the spec. // method should be lowercase (get, post, put, patch, delete). func (s *OpenAPISpec) AddPath(path, method string, operation map[string]interface{}) *OpenAPISpec { s.mu.Lock() defer s.mu.Unlock() if s.Paths[path] == nil { s.Paths[path] = make(map[string]interface{}) } s.Paths[path][method] = operation return s } // JSON returns the spec as JSON bytes. func (s *OpenAPISpec) JSON() ([]byte, error) { s.mu.RLock() defer s.mu.RUnlock() return json.MarshalIndent(s, "", " ") } // EnableDocs adds /docs and /openapi.json endpoints to the app. func (a *App) EnableDocs(spec *OpenAPISpec) { // Serve OpenAPI JSON a.router.Get("/openapi.json", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") specBytes, err := spec.JSON() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _, _ = w.Write(specBytes) }) // Serve Scalar docs UI a.router.Get("/docs", func(w http.ResponseWriter, r *http.Request) { // Detect scheme: check X-Forwarded-Proto first (for reverse proxy/TLS termination), // then fall back to r.TLS for direct HTTPS connections scheme := "http" if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { scheme = proto } else if r.TLS != nil { scheme = "https" } specURL := fmt.Sprintf("%s://%s/openapi.json", scheme, r.Host) html, err := scalargo.NewV2( scalargo.WithSpecURL(specURL), scalargo.WithDarkMode(), ) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = fmt.Fprint(w, html) }) a.logger.Info("API documentation enabled", "docs", "/docs", "spec", "/openapi.json") } // Op creates an OpenAPI operation helper. func Op(summary, description string, tags ...string) map[string]any { return map[string]any{ "summary": summary, "description": description, "tags": tags, "responses": map[string]any{ "200": map[string]any{"description": "Success"}, }, } } // OpWithBody creates an OpenAPI operation with a request body. func OpWithBody(summary, description string, tags ...string) map[string]any { return map[string]any{ "summary": summary, "description": description, "tags": tags, "requestBody": map[string]any{ "required": true, "content": map[string]any{ "application/json": map[string]any{ "schema": map[string]any{ "type": "object", }, }, }, }, "responses": map[string]any{ "200": map[string]any{"description": "Success"}, "201": map[string]any{"description": "Created"}, }, } }