diff --git a/engine.go b/engine.go index 84079a16..2f53b21c 100644 --- a/engine.go +++ b/engine.go @@ -176,6 +176,17 @@ func WithOpenAPIGeneratorSchemaCustomizer(sc openapi3gen.SchemaCustomizerFn, opt } } +// WithWalkSchemas registers a function that will be called on every schema +// in the OpenAPI document during [Engine.OutputOpenAPISpec]. The callback may +// modify schemas in place, making it useful for post-processing transformations +// like converting nullable representations or normalizing types. +// Multiple calls append additional walk functions; they run in registration order. +func WithWalkSchemas(fn openapi3.WalkSchemasFunc) EngineOption { + return func(e *Engine) { + e.OpenAPI.walkSchemasFns = append(e.OpenAPI.walkSchemasFns, fn) + } +} + // WithErrorHandler sets a customer error handler for the server func WithErrorHandler(errorHandler func(ctx context.Context, err error) error) EngineOption { return func(e *Engine) { @@ -207,6 +218,10 @@ func (e *Engine) OutputOpenAPISpec() *openapi3.T { // spec generation e.OpenAPI.resolveSchemaRefs() + for _, fn := range e.OpenAPI.walkSchemasFns { + e.OpenAPI.Description().WalkSchemas(fn) + } + // Validate err := e.OpenAPI.Description().Validate(context.Background()) if err != nil { diff --git a/examples/basic/main.go b/examples/basic/main.go index 6fb235d9..707bd0a6 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -1,84 +1,50 @@ package main import ( - "context" - "errors" - "net/http" - "strings" - - chiMiddleware "github.com/go-chi/chi/v5/middleware" - "github.com/rs/cors" + "encoding/json" + "fmt" + "github.com/getkin/kin-openapi/openapi3" "github.com/go-fuego/fuego" - "github.com/go-fuego/fuego/option" ) -type Received struct { - Name string `json:"name" validate:"required"` +type Owner struct { + Name string `json:"name"` } -type MyResponse struct { - Message string `json:"message"` - BestFramework string `json:"best"` +type Widget struct { + Name *string `json:"name"` // pointer -> nullable scalar + Tags []string `json:"tags"` // slice + Owner *Owner `json:"owner"` // pointer to a named struct -> $ref } func main() { s := fuego.NewServer( - fuego.WithAddr("localhost:8088"), - ) - - fuego.Use(s, cors.Default().Handler) - fuego.Use(s, chiMiddleware.Compress(5, "text/html", "text/css")) - - // Fuego 🔥 handler with automatic OpenAPI generation, validation, (de)serialization and error handling - fuego.Post(s, "/", func(c fuego.ContextWithBody[Received]) (MyResponse, error) { - data, err := c.Body() - if err != nil { - return MyResponse{}, err - } - - // read the request header test - if c.Request().Header.Get("test") != "test" { - return MyResponse{}, errors.New("test header not equal to 'test'") - } - - c.Response().Header().Set("X-Hello", "World") - - return MyResponse{ - Message: "Hello, " + data.Name, - BestFramework: "Fuego!", - }, nil - }, - option.Description("Say hello to the world"), - option.Header("test", "Just a test header"), - option.Cookie("test", "A Cookie!"), + fuego.WithEngineOptions( + fuego.WithWalkSchemas(func(_ string, ref *openapi3.SchemaRef) error { + schema := ref.Value + if schema.Nullable && schema.Type != nil && !schema.Type.Includes("null") { + *schema.Type = append(*schema.Type, "null") + schema.Nullable = false + } + return nil + }), + ), ) - // Standard net/http handler with automatic OpenAPI route declaration - fuego.GetStd(s, "/std", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Hello, World!")) + fuego.Get(s, "/widget", func(c fuego.ContextNoBody) (Widget, error) { + return Widget{}, nil }) - s.Run() -} + // Owner is also returned on its own, where it is never null. + fuego.Get(s, "/owner", func(c fuego.ContextNoBody) (Owner, error) { + return Owner{}, nil + }) -// InTransform will be called when using c.Body(). -// It can be used to transform the entity and raise custom errors -func (r *Received) InTransform(context.Context) error { - r.Name = strings.ToLower(r.Name) - if r.Name == "fuego" { - return errors.New("fuego is not a name") + doc := s.OutputOpenAPISpec() + fmt.Println("openapi:", doc.OpenAPI) + for _, name := range []string{"Widget", "Owner"} { + out, _ := json.MarshalIndent(doc.Components.Schemas[name].Value, "", " ") + fmt.Printf("%s: %s\n", name, out) } - return nil -} - -// OutTransform will be called before sending data -func (r *MyResponse) OutTransform(context.Context) error { - r.Message = strings.ToUpper(r.Message) - return nil } - -var ( - _ fuego.InTransformer = &Received{} // Ensure that *Received implements fuego.InTransformer - _ fuego.OutTransformer = &MyResponse{} // Ensure that *MyResponse implements fuego.OutTransformer -) diff --git a/openapi.go b/openapi.go index 8f0a9e7f..da155ffb 100644 --- a/openapi.go +++ b/openapi.go @@ -35,6 +35,7 @@ type OpenAPI struct { generator *openapi3gen.Generator globalOpenAPIResponses []openAPIResponse Config OpenAPIConfig + walkSchemasFns []openapi3.WalkSchemasFunc } func (openAPI *OpenAPI) Description() *openapi3.T { diff --git a/schema_customizer.go b/schema_customizer.go index 5efc7958..79f2d857 100644 --- a/schema_customizer.go +++ b/schema_customizer.go @@ -138,7 +138,7 @@ func determineFieldConstraints(t reflect.Type, schema *openapi3.Schema) { continue } if isReferenceType(f.Type.Kind()) && !hasRequired { - if !prop.Value.Type.Includes("null") { + if prop.Value != nil && !prop.Value.Type.Includes("null") { types := openapi3.Types(append(prop.Value.Type.Slice(), "null")) prop.Value.Type = &types } diff --git a/schema_customizer_test.go b/schema_customizer_test.go index c5c72244..9ec35b0d 100644 --- a/schema_customizer_test.go +++ b/schema_customizer_test.go @@ -102,6 +102,22 @@ func TestDetermineFieldConstraints(t *testing.T) { assert.True(t, schema.Properties["meta"].Value.Type.Includes("null")) }) + t.Run("pointer field is not handled by determineFieldConstraints", func(t *testing.T) { + type S struct { + Name *string `json:"name"` + } + schema := &openapi3.Schema{ + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Nullable: true, + }}, + }, + } + determineFieldConstraints(reflect.TypeFor[S](), schema) + assert.True(t, schema.Properties["name"].Value.Nullable) + }) + t.Run("string field is not nullable", func(t *testing.T) { type S struct { Name string `json:"name"`