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

BREAKING: create generic engine.OutputOpenAPISpec for use in all engines #302

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
140 changes: 134 additions & 6 deletions engine.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,144 @@
package fuego

func NewEngine() *Engine {
return &Engine{
OpenAPI: NewOpenAPI(),
ErrorHandler: ErrorHandler,
import (
"context"
"encoding/json"
"errors"
"log/slog"
"os"
"path/filepath"
)

// NewEngine creates a new Engine with the given options.
// For example:
//
// engine := fuego.NewEngin(
// WithOpenAPIConfig(
// OpenAPIConfig{
// PrettyFormatJSON: true,
// },
// ),
// )
//
// Options all begin with `With`.
func NewEngine(options ...func(*Engine)) *Engine {
e := &Engine{
OpenAPI: NewOpenAPI(),
OpenAPIConfig: defaultOpenAPIConfig,
ErrorHandler: ErrorHandler,
}
for _, option := range options {
option(e)
}
return e
}
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved

// The Engine is the main struct of the framework.
type Engine struct {
OpenAPI *OpenAPI
ErrorHandler func(error) error
OpenAPI *OpenAPI
ErrorHandler func(error) error
OpenAPIConfig OpenAPIConfig

acceptedContentTypes []string
}

type OpenAPIConfig struct {
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved
// If true, the server will not serve nor generate any OpenAPI resources
Disabled bool
EwenQuim marked this conversation as resolved.
Show resolved Hide resolved
// If true, the engine will not print messages
DisableMessages bool
// If true, the engine will not save the OpenAPI JSON spec locally
DisableLocalSave bool
// Local path to save the OpenAPI JSON spec
JSONFilePath string
// Pretty prints the OpenAPI spec with proper JSON indentation
PrettyFormatJSON bool
}

var defaultOpenAPIConfig = OpenAPIConfig{
JSONFilePath: "doc/openapi.json",
}

func WithOpenAPIConfig(config OpenAPIConfig) func(*Engine) {
return func(e *Engine) {
if config.JSONFilePath != "" {
e.OpenAPIConfig.JSONFilePath = config.JSONFilePath
}

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

// WithErrorHandler sets a customer error handler for the server
func WithErrorHandler(errorHandler func(err error) error) func(*Engine) {
return func(e *Engine) {
if errorHandler == nil {
panic("errorHandler cannot be nil")
}

e.ErrorHandler = errorHandler
}
}

// OutputOpenAPISpec takes the OpenAPI spec and outputs it to a JSON file
func (e *Engine) OutputOpenAPISpec() []byte {
e.OpenAPI.computeTags()

// Validate
err := e.OpenAPI.Description().Validate(context.Background())
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
slog.Error("Error validating spec", "error", err)
}

// Marshal spec to JSON
jsonSpec, err := e.marshalSpec()
if err != nil {
slog.Error("Error marshaling spec to JSON", "error", err)
}

if !e.OpenAPIConfig.DisableLocalSave {
err := e.saveOpenAPIToFile(e.OpenAPIConfig.JSONFilePath, jsonSpec)
if err != nil {
slog.Error("Error saving spec to local path", "error", err, "path", e.OpenAPIConfig.JSONFilePath)
}
}
return jsonSpec
}

func (e *Engine) saveOpenAPIToFile(jsonSpecLocalPath string, jsonSpec []byte) error {
jsonFolder := filepath.Dir(jsonSpecLocalPath)

err := os.MkdirAll(jsonFolder, 0o750)
if err != nil {
return errors.New("error creating docs directory")
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use fmt.Errorf("error creating docs directory: %w", err)

}

f, err := os.Create(jsonSpecLocalPath) // #nosec G304 (file path provided by developer, not by user)
if err != nil {
return errors.New("error creating file")
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same

}
defer f.Close()

_, err = f.Write(jsonSpec)
if err != nil {
return errors.New("error writing file ")
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same

}
dylanhitt marked this conversation as resolved.
Show resolved Hide resolved

e.printOpenAPIMessage("JSON file: " + jsonSpecLocalPath)
return nil
}

func (s *Engine) marshalSpec() ([]byte, error) {
if s.OpenAPIConfig.PrettyFormatJSON {
return json.MarshalIndent(s.OpenAPI.Description(), "", "\t")
}
return json.Marshal(s.OpenAPI.Description())
}

func (e *Engine) printOpenAPIMessage(msg string) {
if !e.OpenAPIConfig.DisableMessages {
slog.Info(msg)
}
}
40 changes: 40 additions & 0 deletions engine_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package fuego

import (
"errors"
"fmt"
"testing"

"github.com/stretchr/testify/require"
)

func TestWithErrorHandler(t *testing.T) {
t.Run("default engine", func(t *testing.T) {
e := NewEngine()
err := NotFoundError{
Err: errors.New("Not Found :c"),
}
errResponse := e.ErrorHandler(err)
require.ErrorAs(t, errResponse, &HTTPError{})
})
t.Run("custom handler", func(t *testing.T) {
e := NewEngine(
WithErrorHandler(func(err error) error {
return fmt.Errorf("%w foobar", err)
}),
)
err := NotFoundError{
Err: errors.New("Not Found :c"),
}
errResponse := e.ErrorHandler(err)
require.ErrorAs(t, errResponse, &HTTPError{})
require.ErrorContains(t, errResponse, "Not Found :c foobar")
})
t.Run("should be fatal", func(t *testing.T) {
require.Panics(t, func() {
NewEngine(
WithErrorHandler(nil),
)
})
})
}
8 changes: 5 additions & 3 deletions examples/generate-opengraph-image/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ var optionReturnsPNG = func(br *fuego.BaseRoute) {

func main() {
s := fuego.NewServer(
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
PrettyFormatJson: true,
}),
fuego.WithEngineOptions(
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
PrettyFormatJSON: true,
}),
),
)

fuego.GetStd(s, "/{title}", controller.OpenGraphHandler,
Expand Down
10 changes: 6 additions & 4 deletions examples/petstore/lib/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import (
func TestPetstoreOpenAPIGeneration(t *testing.T) {
server := NewPetStoreServer(
fuego.WithoutStartupMessages(),
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
JsonFilePath: "testdata/doc/openapi.json",
PrettyFormatJson: true,
}),
fuego.WithEngineOptions(
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
JSONFilePath: "testdata/doc/openapi.json",
PrettyFormatJSON: true,
}),
),
)

server.OutputOpenAPISpec()
Expand Down
99 changes: 21 additions & 78 deletions openapi.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
package fuego

import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"reflect"
"regexp"
"slices"
Expand Down Expand Up @@ -112,97 +107,45 @@ func (s *Server) OutputOpenAPISpec() openapi3.T {
Description: "local server",
})

s.OpenAPI.computeTags()

// Validate
err := s.OpenAPI.Description().Validate(context.Background())
if err != nil {
slog.Error("Error validating spec", "error", err)
}

// Marshal spec to JSON
jsonSpec, err := s.marshalSpec()
if err != nil {
slog.Error("Error marshaling spec to JSON", "error", err)
}

if !s.OpenAPIConfig.DisableSwagger {
s.registerOpenAPIRoutes(jsonSpec)
}

if !s.OpenAPIConfig.DisableLocalSave {
err := s.saveOpenAPIToFile(s.OpenAPIConfig.JsonFilePath, jsonSpec)
if err != nil {
slog.Error("Error saving spec to local path", "error", err, "path", s.OpenAPIConfig.JsonFilePath)
}
if !s.OpenAPIConfig.Disabled {
s.registerOpenAPIRoutes(s.Engine.OutputOpenAPISpec())
}

return *s.OpenAPI.Description()
}

func (s *Server) marshalSpec() ([]byte, error) {
if s.OpenAPIConfig.PrettyFormatJson {
return json.MarshalIndent(s.OpenAPI.Description(), "", "\t")
}
return json.Marshal(s.OpenAPI.Description())
}

func (s *Server) saveOpenAPIToFile(jsonSpecLocalPath string, jsonSpec []byte) error {
jsonFolder := filepath.Dir(jsonSpecLocalPath)

err := os.MkdirAll(jsonFolder, 0o750)
if err != nil {
return errors.New("error creating docs directory")
}

f, err := os.Create(jsonSpecLocalPath) // #nosec G304 (file path provided by developer, not by user)
if err != nil {
return errors.New("error creating file")
}
defer f.Close()

_, err = f.Write(jsonSpec)
if err != nil {
return errors.New("error writing file ")
}

s.printOpenAPIMessage("JSON file: " + jsonSpecLocalPath)
return nil
}

// Registers the routes to serve the OpenAPI spec and Swagger UI.
func (s *Server) registerOpenAPIRoutes(jsonSpec []byte) {
GetStd(s, s.OpenAPIConfig.JsonUrl, func(w http.ResponseWriter, r *http.Request) {
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.OpenAPIConfig.JsonUrl))
s.printOpenAPIMessage(fmt.Sprintf("JSON spec: %s%s", s.url(), s.OpenAPIServerConfig.SpecURL))

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

func (s *Server) printOpenAPIMessage(msg string) {
if !s.disableStartupMessages {
slog.Info(msg)
}
},
controller: s.OpenAPIServerConfig.UIHandler(s.OpenAPIServerConfig.SpecURL),
})
s.printOpenAPIMessage(fmt.Sprintf("OpenAPI UI: %s%s/index.html", s.url(), s.OpenAPIServerConfig.SwaggerURL))
}

func validateJsonSpecUrl(jsonSpecUrl string) bool {
jsonSpecUrlRegexp := regexp.MustCompile(`^\/[\/a-zA-Z0-9\-\_]+(.json)$`)
return jsonSpecUrlRegexp.MatchString(jsonSpecUrl)
func validateSpecURL(specURL string) bool {
specURLRegexp := regexp.MustCompile(`^\/[\/a-zA-Z0-9\-\_]+(.json)$`)
return specURLRegexp.MatchString(specURL)
}

func validateSwaggerUrl(swaggerUrl string) bool {
swaggerUrlRegexp := regexp.MustCompile(`^\/[\/a-zA-Z0-9\-\_]+[a-zA-Z0-9\-\_]$`)
return swaggerUrlRegexp.MatchString(swaggerUrl)
func validateSwaggerURL(swaggerURL string) bool {
swaggerURLRegexp := regexp.MustCompile(`^\/[\/a-zA-Z0-9\-\_]+[a-zA-Z0-9\-\_]$`)
return swaggerURLRegexp.MatchString(swaggerURL)
}

// RegisterOpenAPIOperation registers the route to the OpenAPI description.
Expand Down
8 changes: 4 additions & 4 deletions openapi_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestUIHandler(t *testing.T) {

s.OutputOpenAPISpec()

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

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

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

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

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

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