From 52f33073eccedaad13f0f4c5cd83d9f16f8f8395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Mouton?= Date: Mon, 4 May 2026 10:04:59 +0200 Subject: [PATCH] Fix time.Time rendering in OpenAPI schema to use date-time format --- fiberoapi.go | 25 ++++++++++ time_type_test.go | 121 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 time_type_test.go diff --git a/fiberoapi.go b/fiberoapi.go index daf4991..c50c03f 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -375,6 +375,11 @@ func collectAllTypes(t reflect.Type, collected map[string]reflect.Type) { // Handle pointers t = dereferenceType(t) + // time.Time is rendered inline as a date-time string, not as a component schema + if isTimeType(t) { + return + } + typeName := getTypeName(t) if typeName == "" { return @@ -571,6 +576,11 @@ func getSimpleTypeName(t reflect.Type) string { } } +// isTimeType reports whether t is the standard library time.Time type. +func isTimeType(t reflect.Type) bool { + return t != nil && t.Kind() == reflect.Struct && t.Name() == "Time" && t.PkgPath() == "time" +} + // generateSchema generates an OpenAPI schema from a Go type func generateSchema(t reflect.Type) map[string]interface{} { if t == nil { @@ -582,6 +592,14 @@ func generateSchema(t reflect.Type) map[string]interface{} { // Handle pointers t = dereferenceType(t) + if isTimeType(t) { + return map[string]interface{}{ + "type": "string", + "format": "date-time", + "example": "2006-01-02T15:04:05Z", + } + } + schema := make(map[string]interface{}) switch t.Kind() { @@ -707,6 +725,13 @@ func generateFieldSchema(t reflect.Type) map[string]interface{} { // Handle pointers t = dereferenceType(t) + if isTimeType(t) { + schema["type"] = "string" + schema["format"] = "date-time" + schema["example"] = "2006-01-02T15:04:05Z" + return schema + } + switch t.Kind() { case reflect.String: schema["type"] = "string" diff --git a/time_type_test.go b/time_type_test.go new file mode 100644 index 0000000..d134b94 --- /dev/null +++ b/time_type_test.go @@ -0,0 +1,121 @@ +package fiberoapi + +import ( + "encoding/json" + "io" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" +) + +type Workspace struct { + WorkspaceID string `json:"workspace_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type WorkspaceResponse struct { + Workspaces []Workspace `json:"workspaces"` +} + +type EmptyRequest struct{} + +func TestTimeTypeRendersAsDateTimeString(t *testing.T) { + app := fiber.New() + oapi := New(app) + + Post(oapi, "/workspaces", func(c *fiber.Ctx, req *EmptyRequest) (*WorkspaceResponse, *ErrorResponse) { + return &WorkspaceResponse{}, nil + }, OpenAPIOptions{ + OperationID: "listWorkspaces", + Tags: []string{"workspaces"}, + }) + + oapi.SetupDocs() + + req := httptest.NewRequest("GET", "/openapi.json", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + body, _ := io.ReadAll(resp.Body) + var spec map[string]interface{} + if err := json.Unmarshal(body, &spec); err != nil { + t.Fatalf("Failed to parse OpenAPI JSON: %v", err) + } + + components := spec["components"].(map[string]interface{}) + schemas := components["schemas"].(map[string]interface{}) + + if _, exists := schemas["Time"]; exists { + t.Errorf("time.Time should not be registered as a 'Time' component schema") + } + + workspaceSchema, ok := schemas["Workspace"].(map[string]interface{}) + if !ok { + t.Fatal("Expected Workspace schema to be present") + } + props := workspaceSchema["properties"].(map[string]interface{}) + + for _, field := range []string{"created_at", "updated_at"} { + f, ok := props[field].(map[string]interface{}) + if !ok { + t.Fatalf("Expected Workspace.%s to be an object", field) + } + if f["type"] != "string" { + t.Errorf("Expected %s.type to be 'string', got %v", field, f["type"]) + } + if f["format"] != "date-time" { + t.Errorf("Expected %s.format to be 'date-time', got %v", field, f["format"]) + } + if _, hasRef := f["$ref"]; hasRef { + t.Errorf("Expected %s to be inlined, not a $ref", field) + } + } +} + +type EventWithPointerTime struct { + Name string `json:"name"` + StartedAt *time.Time `json:"started_at,omitempty"` +} + +func TestPointerTimeTypeRendersAsDateTimeString(t *testing.T) { + app := fiber.New() + oapi := New(app) + + Post(oapi, "/events", func(c *fiber.Ctx, req *EmptyRequest) (*EventWithPointerTime, *ErrorResponse) { + return &EventWithPointerTime{}, nil + }, OpenAPIOptions{ + OperationID: "createEvent", + Tags: []string{"events"}, + }) + + oapi.SetupDocs() + + req := httptest.NewRequest("GET", "/openapi.json", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + body, _ := io.ReadAll(resp.Body) + var spec map[string]interface{} + if err := json.Unmarshal(body, &spec); err != nil { + t.Fatalf("Failed to parse OpenAPI JSON: %v", err) + } + + schemas := spec["components"].(map[string]interface{})["schemas"].(map[string]interface{}) + eventSchema := schemas["EventWithPointerTime"].(map[string]interface{}) + props := eventSchema["properties"].(map[string]interface{}) + + startedAt, ok := props["started_at"].(map[string]interface{}) + if !ok { + t.Fatal("Expected started_at property to be present") + } + if startedAt["type"] != "string" || startedAt["format"] != "date-time" { + t.Errorf("Expected *time.Time to render as string/date-time, got %v", startedAt) + } +}