Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Common OpenAPI serving pattern through OpenAPIServable interface #344

Merged
merged 3 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"

"github.com/getkin/kin-openapi/openapi3"
)

// NewEngine creates a new Engine with the given options.
Expand Down Expand Up @@ -53,10 +56,21 @@ type OpenAPIConfig struct {
DisableLocalSave bool
// Pretty prints the OpenAPI spec with proper JSON indentation
PrettyFormatJSON bool
// URL to serve the OpenAPI JSON spec
SpecURL string
// Handler to serve the OpenAPI UI from spec URL
UIHandler func(specURL string) http.Handler
// URL to serve the swagger UI
SwaggerURL string
// If true, the server will not serve the Swagger UI
DisableSwaggerUI bool
}
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved

var defaultOpenAPIConfig = OpenAPIConfig{
JSONFilePath: "doc/openapi.json",
SpecURL: "/swagger/openapi.json",
SwaggerURL: "/swagger",
UIHandler: DefaultOpenAPIHandler,
}

// WithRequestContentType sets the accepted content types for the engine.
Expand All @@ -70,10 +84,29 @@ func WithOpenAPIConfig(config OpenAPIConfig) func(*Engine) {
if config.JSONFilePath != "" {
e.OpenAPIConfig.JSONFilePath = config.JSONFilePath
}
if config.SpecURL != "" {
e.OpenAPIConfig.SpecURL = config.SpecURL
}
if config.SwaggerURL != "" {
e.OpenAPIConfig.SwaggerURL = config.SwaggerURL
}
if config.UIHandler != nil {
e.OpenAPIConfig.UIHandler = config.UIHandler
}

e.OpenAPIConfig.Disabled = config.Disabled
e.OpenAPIConfig.DisableLocalSave = config.DisableLocalSave
e.OpenAPIConfig.PrettyFormatJSON = config.PrettyFormatJSON
e.OpenAPIConfig.DisableSwaggerUI = config.DisableSwaggerUI

if !validateSpecURL(e.OpenAPIConfig.SpecURL) {
slog.Error("Error serving OpenAPI JSON spec. Value of 's.OpenAPIServerConfig.SpecURL' option is not valid", "url", e.OpenAPIConfig.SpecURL)
return
}
if !validateSwaggerURL(e.OpenAPIConfig.SwaggerURL) {
slog.Error("Error serving Swagger UI. Value of 's.OpenAPIServerConfig.SwaggerURL' option is not valid", "url", e.OpenAPIConfig.SwaggerURL)
return
}
}
}

Expand All @@ -95,8 +128,14 @@ func DisableErrorHandler() func(*Engine) {
}
}

func (e *Engine) SpecHandler() func(c ContextNoBody) (openapi3.T, error) {
return func(c ContextNoBody) (openapi3.T, error) {
return *e.OpenAPI.Description(), nil
}
}

dylanhitt marked this conversation as resolved.
Show resolved Hide resolved
// OutputOpenAPISpec takes the OpenAPI spec and outputs it to a JSON file
func (e *Engine) OutputOpenAPISpec() []byte {
func (e *Engine) OutputOpenAPISpec() *openapi3.T {
e.OpenAPI.computeTags()

// Validate
Expand All @@ -117,7 +156,7 @@ func (e *Engine) OutputOpenAPISpec() []byte {
slog.Error("Error saving spec to local path", "error", err, "path", e.OpenAPIConfig.JSONFilePath)
}
}
return jsonSpec
return e.OpenAPI.Description()
}

func (e *Engine) saveOpenAPIToFile(jsonSpecLocalPath string, jsonSpec []byte) error {
Expand Down
14 changes: 0 additions & 14 deletions examples/gin-compat/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package main

import (
"fmt"
"net/http"

"github.com/gin-gonic/gin"

Expand Down Expand Up @@ -38,16 +37,3 @@ func fuegoControllerPost(c fuego.ContextWithBody[HelloRequest]) (*HelloResponse,
Message: fmt.Sprintf("Hello %s, %s", body.Word, name),
}, nil
}

func serveOpenApiJSONDescription(s *fuego.OpenAPI) func(ctx *gin.Context) {
return func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, s.Description())
}
}

func DefaultOpenAPIHandler(specURL string) gin.HandlerFunc {
return func(ctx *gin.Context) {
ctx.Header("Content-Type", "text/html; charset=utf-8")
ctx.String(200, fuego.DefaultOpenAPIHTML(specURL))
}
}
5 changes: 2 additions & 3 deletions examples/gin-compat/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,9 @@ func server() (*gin.Engine, *fuego.OpenAPI) {
option.Tags("Fuego"),
)

// Serve the OpenAPI spec
ginRouter.GET("/openapi.json", serveOpenApiJSONDescription(engine.OpenAPI))
ginRouter.GET("/swagger", DefaultOpenAPIHandler("/openapi.json"))
engine.RegisterOpenAPIRoutes(&fuegogin.OpenAPIHandler{ginRouter})

// Serve the OpenAPI spec
return ginRouter, engine.OpenAPI
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved
}

Expand Down
1 change: 1 addition & 0 deletions examples/petstore/lib/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func TestPetstoreOpenAPIGeneration(t *testing.T) {
),
)

server.Engine.RegisterOpenAPIRoutes(server)
server.OutputOpenAPISpec()
err := server.OpenAPI.Description().Validate(context.Background())
require.NoError(t, err)
Expand Down
6 changes: 0 additions & 6 deletions examples/petstore/lib/testdata/doc/openapi.golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -1556,12 +1556,6 @@
}
}
},
"servers": [
{
"description": "local server",
"url": "http://localhost:9999"
}
],
"tags": [
{
EwenQuim marked this conversation as resolved.
Show resolved Hide resolved
"name": "my-tag"
Expand Down
18 changes: 18 additions & 0 deletions extra/fuegogin/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ import (
"github.com/go-fuego/fuego/internal"
)

type OpenAPIHandler struct {
GinEngine *gin.Engine
}

func (o *OpenAPIHandler) SpecHandler(e *fuego.Engine) {
Get(e, o.GinEngine, e.OpenAPIConfig.SpecURL, e.SpecHandler(), fuego.OptionHide())
}

func (o *OpenAPIHandler) UIHandler(e *fuego.Engine) {
GetGin(
e,
o.GinEngine,
e.OpenAPIConfig.SwaggerURL+"/",
gin.WrapH(e.OpenAPIConfig.UIHandler(e.OpenAPIConfig.SpecURL)),
fuego.OptionHide(),
)
}

func GetGin(engine *fuego.Engine, ginRouter gin.IRouter, path string, handler gin.HandlerFunc, options ...func(*fuego.BaseRoute)) *fuego.Route[any, any] {
return handleGin(engine, ginRouter, http.MethodGet, path, handler, options...)
}
Expand Down
57 changes: 17 additions & 40 deletions openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,23 @@ func NewOpenApiSpec() openapi3.T {
return spec
}

type OpenAPIServable interface {
SpecHandler(e *Engine)
UIHandler(e *Engine)
}

func (e *Engine) RegisterOpenAPIRoutes(o OpenAPIServable) {
if e.OpenAPIConfig.Disabled {
return
}
o.SpecHandler(e)

if e.OpenAPIConfig.DisableSwaggerUI {
return
}
o.UIHandler(e)
}

// Hide prevents the routes in this server or group from being included in the OpenAPI spec.
// Deprecated: Please use [OptionHide] with [WithRouteOptions]
func (s *Server) Hide() *Server {
Expand All @@ -99,46 +116,6 @@ func (s *Server) Show() *Server {
return s
}

// OutputOpenAPISpec takes the OpenAPI spec and outputs it to a JSON file and/or serves it on a URL.
// Also serves a Swagger UI.
// To modify its behavior, use the [WithOpenAPIConfig] option.
func (s *Server) OutputOpenAPISpec() openapi3.T {
s.OpenAPI.Description().Servers = append(s.OpenAPI.Description().Servers, &openapi3.Server{
URL: s.url(),
Description: "local server",
})

if !s.OpenAPIConfig.Disabled {
s.registerOpenAPIRoutes(s.Engine.OutputOpenAPISpec())
}

return *s.OpenAPI.Description()
}

// Registers the routes to serve the OpenAPI spec and Swagger UI.
func (s *Server) registerOpenAPIRoutes(jsonSpec []byte) {
GetStd(s, s.OpenAPIServerConfig.SpecURL, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(jsonSpec)
})
s.printOpenAPIMessage(fmt.Sprintf("JSON spec: %s%s", s.url(), s.OpenAPIServerConfig.SpecURL))

if s.OpenAPIServerConfig.DisableSwaggerUI {
return
}
Registers(s.Engine, netHttpRouteRegisterer[any, any]{
s: s,
route: Route[any, any]{
BaseRoute: BaseRoute{
Method: http.MethodGet,
Path: s.OpenAPIServerConfig.SwaggerURL + "/",
},
},
controller: s.OpenAPIServerConfig.UIHandler(s.OpenAPIServerConfig.SpecURL),
})
s.printOpenAPIMessage(fmt.Sprintf("OpenAPI UI: %s%s/index.html", s.url(), s.OpenAPIServerConfig.SwaggerURL))
}

func validateSpecURL(specURL string) bool {
specURLRegexp := regexp.MustCompile(`^\/[\/a-zA-Z0-9\-\_]+(.json)$`)
return specURLRegexp.MatchString(specURL)
Expand Down
4 changes: 1 addition & 3 deletions openapi_handler.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package fuego

import (
"net/http"
)
import "net/http"

func DefaultOpenAPIHandler(specURL string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
30 changes: 17 additions & 13 deletions openapi_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ func TestUIHandler(t *testing.T) {
t.Run("works with DefaultOpenAPIHandler", func(t *testing.T) {
s := NewServer()

s.OutputOpenAPISpec()
s.Engine.RegisterOpenAPIRoutes(s)

require.NotNil(t, s.OpenAPIServerConfig.UIHandler)
require.NotNil(t, s.OpenAPIConfig.UIHandler)

w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/swagger/index.html", nil)
Expand All @@ -37,15 +37,17 @@ func TestUIHandler(t *testing.T) {

t.Run("wrap DefaultOpenAPIHandler behind a middleware", func(t *testing.T) {
s := NewServer(
WithOpenAPIServerConfig(OpenAPIServerConfig{
UIHandler: func(specURL string) http.Handler {
return dummyMiddleware(DefaultOpenAPIHandler(specURL))
},
}),
WithEngineOptions(
WithOpenAPIConfig(OpenAPIConfig{
UIHandler: func(specURL string) http.Handler {
return dummyMiddleware(DefaultOpenAPIHandler(specURL))
},
}),
),
)
s.OutputOpenAPISpec()
s.Engine.RegisterOpenAPIRoutes(s)

require.NotNil(t, s.OpenAPIServerConfig.UIHandler)
require.NotNil(t, s.OpenAPIConfig.UIHandler)

w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/swagger/index.html", nil)
Expand All @@ -59,12 +61,14 @@ func TestUIHandler(t *testing.T) {

t.Run("disabling UI", func(t *testing.T) {
s := NewServer(
WithOpenAPIServerConfig(OpenAPIServerConfig{
DisableSwaggerUI: true,
}),
WithEngineOptions(
WithOpenAPIConfig(OpenAPIConfig{
DisableSwaggerUI: true,
}),
),
)

s.OutputOpenAPISpec()
s.Engine.RegisterOpenAPIRoutes(s)

w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/swagger/index.html", nil)
Expand Down
7 changes: 7 additions & 0 deletions serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"net/http"
"reflect"
"time"

"github.com/getkin/kin-openapi/openapi3"
)

// Run starts the server.
Expand Down Expand Up @@ -36,7 +38,12 @@ func (s *Server) setup() error {
if err := s.setupDefaultListener(); err != nil {
return err
}
s.OpenAPI.Description().Servers = append(s.OpenAPI.Description().Servers, &openapi3.Server{
URL: s.url(),
Description: "local server",
})
go s.OutputOpenAPISpec()
s.Engine.RegisterOpenAPIRoutes(s)
s.printStartupMessage()

s.Server.Handler = s.Mux
Expand Down
Loading