diff --git a/examples/constraints/api.yaml b/examples/constraints/api.yaml new file mode 100644 index 0000000000..e944f6850d --- /dev/null +++ b/examples/constraints/api.yaml @@ -0,0 +1,156 @@ +openapi: 3.0.0 +info: + title: Constraints Demo API + version: 1.0.0 + description: Demo API to showcase constraint constants generation for both schemas and inline parameters + +paths: + /users: + get: + operationId: listUsers + summary: List users with pagination and filtering + description: Demonstrates inline parameter constraints generating constants + parameters: + - in: query + name: limit + description: Maximum number of items to return + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - in: query + name: offset + description: Number of items to skip + schema: + type: integer + minimum: 0 + - in: query + name: search + description: Search query string + schema: + type: string + minLength: 3 + maxLength: 50 + - in: query + name: minScore + description: Minimum score filter + schema: + type: number + format: float + minimum: 0.0 + maximum: 100.0 + responses: + "200": + description: List of users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + post: + operationId: createUser + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "201": + description: User created + content: + application/json: + schema: + $ref: "#/components/schemas/User" + + /users/{userId}: + get: + operationId: getUser + summary: Get a specific user by ID + parameters: + - in: path + name: userId + required: true + description: User ID (UUID format) + schema: + type: string + minLength: 36 + maxLength: 36 + responses: + "200": + description: User details + content: + application/json: + schema: + $ref: "#/components/schemas/User" + +components: + schemas: + # These are defined as standalone types so constraints become constants + Username: + type: string + minLength: 3 + maxLength: 20 + default: "guest" + description: Username must be between 3 and 20 characters + + Age: + type: integer + minimum: 0 + maximum: 150 + default: 18 + description: User's age in years + + UserScore: + type: number + format: float + minimum: 0.0 + maximum: 100.0 + default: 50.0 + description: User score between 0 and 100 + + Port: + type: integer + format: int32 + minimum: 1024 + maximum: 65535 + default: 8080 + description: Preferred port number + + IsActive: + type: boolean + default: true + description: Whether the account is active + + UserTags: + type: array + items: + type: string + minItems: 1 + maxItems: 10 + description: User tags (1-10 items) + + User: + type: object + required: + - username + - age + properties: + username: + $ref: "#/components/schemas/Username" + age: + $ref: "#/components/schemas/Age" + email: + type: string + format: email + maxLength: 100 + isActive: + $ref: "#/components/schemas/IsActive" + score: + $ref: "#/components/schemas/UserScore" + port: + $ref: "#/components/schemas/Port" + tags: + $ref: "#/components/schemas/UserTags" diff --git a/examples/constraints/cfg.yaml b/examples/constraints/cfg.yaml new file mode 100644 index 0000000000..5ba2c17c5a --- /dev/null +++ b/examples/constraints/cfg.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=../../configuration-schema.json +package: constraints +output: gen.go +generate: + models: true + echo-server: true +output-options: + skip-prune: true diff --git a/examples/constraints/example.go b/examples/constraints/example.go new file mode 100644 index 0000000000..aeb258cbdb --- /dev/null +++ b/examples/constraints/example.go @@ -0,0 +1,178 @@ +package constraints + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo/v4" +) + +// Example implementation showing how to use the generated constraint constants for both schema types and inline parameters +type Server struct{} + +func NewServer() *Server { + return &Server{} +} + +// ListUsers demonstrates using constraint constants from inline parameters +func (s *Server) ListUsers(ctx echo.Context, params ListUsersParams) error { + // Validate limit parameter using generated constants + if params.Limit != nil { + if *params.Limit < ListUsersLimitMinimum || *params.Limit > ListUsersLimitMaximum { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Limit must be between %d and %d", ListUsersLimitMinimum, ListUsersLimitMaximum)) + } + } else { + // Use the generated default constant + defaultLimit := ListUsersLimitDefault + params.Limit = &defaultLimit + } + + // Validate offset parameter + if params.Offset != nil { + if *params.Offset < ListUsersOffsetMinimum { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Offset must be at least %d", ListUsersOffsetMinimum)) + } + } + + // Validate search parameter length + if params.Search != nil { + searchLen := uint64(len(*params.Search)) + if searchLen < ListUsersSearchMinLength { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Search must be at least %d characters", ListUsersSearchMinLength)) + } + if searchLen > ListUsersSearchMaxLength { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Search must not exceed %d characters", ListUsersSearchMaxLength)) + } + } + + // Validate minScore parameter + if params.MinScore != nil { + if *params.MinScore < ListUsersMinScoreMinimum || *params.MinScore > ListUsersMinScoreMaximum { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("MinScore must be between %.1f and %.1f", ListUsersMinScoreMinimum, ListUsersMinScoreMaximum)) + } + } + + fmt.Printf("Listing users with limit=%d, offset=%d\n", *params.Limit, *params.Offset) + + // Return mock data (implementation not shown) + users := []User{} + return ctx.JSON(http.StatusOK, users) +} + +// GetUser demonstrates using constraint constants from path parameters +func (s *Server) GetUser(ctx echo.Context, userId string) error { + // Validate userId length using generated constants + userIdLen := uint64(len(userId)) + if userIdLen < GetUserUserIdMinLength || userIdLen > GetUserUserIdMaxLength { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("UserId must be exactly %d characters", GetUserUserIdMinLength)) + } + + fmt.Printf("Getting user: %s\n", userId) + + // Return mock data (implementation not shown) + user := User{Username: UsernameDefault, Age: AgeDefault} + return ctx.JSON(http.StatusOK, user) +} + +// CreateUser demonstrates using the generated constraint constants for validation +func (s *Server) CreateUser(ctx echo.Context) error { + var user User + if err := ctx.Bind(&user); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + // Use the generated constraint constants for validation! + + // Validate age constraints + if user.Age < AgeMinimum || user.Age > AgeMaximum { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Age must be between %d and %d", AgeMinimum, AgeMaximum)) + } + + // Validate username length constraints + if uint64(len(user.Username)) < UsernameMinLength { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Username must be at least %d characters", UsernameMinLength)) + } + if uint64(len(user.Username)) > UsernameMaxLength { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Username must not exceed %d characters", UsernameMaxLength)) + } + + // Validate port constraints (if provided) + if user.Port != nil { + if *user.Port < PortMinimum || *user.Port > PortMaximum { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Port must be between %d and %d", PortMinimum, PortMaximum)) + } + } + + // Validate user score constraints (if provided) + if user.Score != nil { + if *user.Score < UserScoreMinimum || *user.Score > UserScoreMaximum { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Score must be between %.1f and %.1f", UserScoreMinimum, UserScoreMaximum)) + } + } + + // Validate tags constraints (if provided) + if user.Tags != nil { + if uint64(len(*user.Tags)) < UserTagsMinItems { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Must provide at least %d tags", UserTagsMinItems)) + } + if uint64(len(*user.Tags)) > UserTagsMaxItems { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Cannot exceed %d tags", UserTagsMaxItems)) + } + } + + // Use default values where appropriate + if user.IsActive == nil { + defaultActive := IsActiveDefault + user.IsActive = &defaultActive + } + if user.Port == nil { + defaultPort := PortDefault + user.Port = &defaultPort + } + if user.Score == nil { + defaultScore := UserScoreDefault + user.Score = &defaultScore + } + + // Create the user (implementation not shown) + fmt.Printf("Creating user: %+v\n", user) + + return ctx.JSON(http.StatusCreated, user) +} + +// Example usage in main function +func Example() { + e := echo.New() + server := NewServer() + RegisterHandlers(e, server) + + fmt.Println("=== Schema Constraint Constants ===") + fmt.Printf("Age range: %d-%d (default: %d)\n", AgeMinimum, AgeMaximum, AgeDefault) + fmt.Printf("Username length: %d-%d (default: %s)\n", UsernameMinLength, UsernameMaxLength, UsernameDefault) + fmt.Printf("Port range: %d-%d (default: %d)\n", PortMinimum, PortMaximum, PortDefault) + fmt.Printf("Score range: %.1f-%.1f (default: %.1f)\n", UserScoreMinimum, UserScoreMaximum, UserScoreDefault) + fmt.Printf("Tags count: %d-%d\n", UserTagsMinItems, UserTagsMaxItems) + fmt.Printf("Is active default: %v\n", IsActiveDefault) + + fmt.Println("\n=== Inline Parameter Constraint Constants ===") + fmt.Printf("ListUsers limit: %d-%d (default: %d)\n", ListUsersLimitMinimum, ListUsersLimitMaximum, ListUsersLimitDefault) + fmt.Printf("ListUsers offset: minimum %d\n", ListUsersOffsetMinimum) + fmt.Printf("ListUsers search length: %d-%d\n", ListUsersSearchMinLength, ListUsersSearchMaxLength) + fmt.Printf("ListUsers minScore: %.1f-%.1f\n", ListUsersMinScoreMinimum, ListUsersMinScoreMaximum) + fmt.Printf("GetUser userId length: %d-%d\n", GetUserUserIdMinLength, GetUserUserIdMaxLength) + + e.Logger.Fatal(e.Start(":8080")) +} diff --git a/examples/constraints/gen.go b/examples/constraints/gen.go new file mode 100644 index 0000000000..a16d7c4270 --- /dev/null +++ b/examples/constraints/gen.go @@ -0,0 +1,256 @@ +// Package constraints provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package constraints + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Constraint constants for Age. +const ( + AgeMinimum int = 0 + AgeMaximum int = 150 + AgeDefault int = 18 +) + +// Constraint constants for IsActive. +const ( + IsActiveDefault bool = true +) + +// Constraint constants for Port. +const ( + PortMinimum int32 = 1024 + PortMaximum int32 = 65535 + PortDefault int32 = 8080 +) + +// Constraint constants for UserScore. +const ( + UserScoreMinimum float32 = 0 + UserScoreMaximum float32 = 100 + UserScoreDefault float32 = 50 +) + +// Constraint constants for UserTags. +const ( + UserTagsMinItems uint64 = 1 + UserTagsMaxItems uint64 = 10 +) + +// Constraint constants for Username. +const ( + UsernameDefault string = "guest" + UsernameMinLength uint64 = 3 + UsernameMaxLength uint64 = 20 +) + +// Constraint constants for ListUsersLimit. +const ( + ListUsersLimitMinimum int = 1 + ListUsersLimitMaximum int = 100 + ListUsersLimitDefault int = 20 +) + +// Constraint constants for ListUsersOffset. +const ( + ListUsersOffsetMinimum int = 0 +) + +// Constraint constants for ListUsersSearch. +const ( + ListUsersSearchMinLength uint64 = 3 + ListUsersSearchMaxLength uint64 = 50 +) + +// Constraint constants for ListUsersMinScore. +const ( + ListUsersMinScoreMinimum float32 = 0 + ListUsersMinScoreMaximum float32 = 100 +) + +// Constraint constants for GetUserUserId. +const ( + GetUserUserIdMinLength uint64 = 36 + GetUserUserIdMaxLength uint64 = 36 +) + +// Age User's age in years +type Age = int + +// IsActive Whether the account is active +type IsActive = bool + +// Port Preferred port number +type Port = int32 + +// User defines model for User. +type User struct { + // Age User's age in years + Age Age `json:"age"` + Email *openapi_types.Email `json:"email,omitempty"` + + // IsActive Whether the account is active + IsActive *IsActive `json:"isActive,omitempty"` + + // Port Preferred port number + Port *Port `json:"port,omitempty"` + + // Score User score between 0 and 100 + Score *UserScore `json:"score,omitempty"` + + // Tags User tags (1-10 items) + Tags *UserTags `json:"tags,omitempty"` + + // Username Username must be between 3 and 20 characters + Username Username `json:"username"` +} + +// UserScore User score between 0 and 100 +type UserScore = float32 + +// UserTags User tags (1-10 items) +type UserTags = []string + +// Username Username must be between 3 and 20 characters +type Username = string + +// ListUsersParams defines parameters for ListUsers. +type ListUsersParams struct { + // Limit Maximum number of items to return + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of items to skip + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + + // Search Search query string + Search *string `form:"search,omitempty" json:"search,omitempty"` + + // MinScore Minimum score filter + MinScore *float32 `form:"minScore,omitempty" json:"minScore,omitempty"` +} + +// CreateUserJSONRequestBody defines body for CreateUser for application/json ContentType. +type CreateUserJSONRequestBody = User + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // List users with pagination and filtering + // (GET /users) + ListUsers(ctx echo.Context, params ListUsersParams) error + + // (POST /users) + CreateUser(ctx echo.Context) error + // Get a specific user by ID + // (GET /users/{userId}) + GetUser(ctx echo.Context, userId string) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// ListUsers converts echo context to params. +func (w *ServerInterfaceWrapper) ListUsers(ctx echo.Context) error { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params ListUsersParams + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", ctx.QueryParams(), ¶ms.Limit) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter limit: %s", err)) + } + + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", ctx.QueryParams(), ¶ms.Offset) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter offset: %s", err)) + } + + // ------------- Optional query parameter "search" ------------- + + err = runtime.BindQueryParameter("form", true, false, "search", ctx.QueryParams(), ¶ms.Search) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter search: %s", err)) + } + + // ------------- Optional query parameter "minScore" ------------- + + err = runtime.BindQueryParameter("form", true, false, "minScore", ctx.QueryParams(), ¶ms.MinScore) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter minScore: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ListUsers(ctx, params) + return err +} + +// CreateUser converts echo context to params. +func (w *ServerInterfaceWrapper) CreateUser(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.CreateUser(ctx) + return err +} + +// GetUser converts echo context to params. +func (w *ServerInterfaceWrapper) GetUser(ctx echo.Context) error { + var err error + // ------------- Path parameter "userId" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "userId", ctx.Param("userId"), &userId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter userId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetUser(ctx, userId) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithBaseURL(router, si, "") +} + +// Registers handlers, and prepends BaseURL to the paths, so that the paths +// can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.GET(baseURL+"/users", wrapper.ListUsers) + router.POST(baseURL+"/users", wrapper.CreateUser) + router.GET(baseURL+"/users/:userId", wrapper.GetUser) + +} diff --git a/examples/constraints/generate.go b/examples/constraints/generate.go new file mode 100644 index 0000000000..0b56d15428 --- /dev/null +++ b/examples/constraints/generate.go @@ -0,0 +1,3 @@ +package constraints + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/internal/test/externalref/petstore/externalref.gen.go b/internal/test/externalref/petstore/externalref.gen.go index 33852217e1..0978b2f449 100644 --- a/internal/test/externalref/petstore/externalref.gen.go +++ b/internal/test/externalref/petstore/externalref.gen.go @@ -43,6 +43,16 @@ const ( FindPetsByStatusParamsStatusSold FindPetsByStatusParamsStatus = "sold" ) +// Constraint constants for FindPetsByStatusParamsStatus. +const ( + FindPetsByStatusParamsStatusDefault string = "available" +) + +// Constraint constants for FindPetsByStatusStatus. +const ( + FindPetsByStatusStatusDefault string = "available" +) + // Address defines model for Address. type Address struct { City *string `json:"city,omitempty"` diff --git a/internal/test/issues/issue-936/api.gen.go b/internal/test/issues/issue-936/api.gen.go index 6b7c0cf220..ff4f2dd965 100644 --- a/internal/test/issues/issue-936/api.gen.go +++ b/internal/test/issues/issue-936/api.gen.go @@ -9,6 +9,11 @@ import ( "github.com/oapi-codegen/runtime" ) +// Constraint constants for FilterPredicate1. +const ( + FilterPredicate1MinLength uint64 = 1 +) + // FilterPredicate defines model for FilterPredicate. type FilterPredicate struct { union json.RawMessage diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 279ed9d081..13ba3dbea2 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -513,6 +513,28 @@ func GenerateTypeDefinitions(t *template.Template, swagger *openapi3.T, ops []Op return "", fmt.Errorf("error generating code for type enums: %w", err) } + // Collect all types for constraint generation, including parameter types from operations + constraintTypes := allTypes + for _, op := range ops { + constraintTypes = append(constraintTypes, op.TypeDefinitions...) + // Extract inline parameter schemas with constraints + for _, param := range op.AllParams() { + if param.Schema.OAPISchema != nil && hasConstraints(param.Schema.OAPISchema) { + // Create a type definition for this parameter's schema + typeName := op.OperationId + SchemaNameToTypeName(param.ParamName) + constraintTypes = append(constraintTypes, TypeDefinition{ + TypeName: typeName, + Schema: param.Schema, + }) + } + } + } + + constraintsOut, err := GenerateConstraints(t, constraintTypes) + if err != nil { + return "", fmt.Errorf("error generating code for type constraints: %w", err) + } + typesOut, err := GenerateTypes(t, allTypes) if err != nil { return "", fmt.Errorf("error generating code for type definitions: %w", err) @@ -533,7 +555,7 @@ func GenerateTypeDefinitions(t *template.Template, swagger *openapi3.T, ops []Op return "", fmt.Errorf("error generating boilerplate for union types with additionalProperties: %w", err) } - typeDefinitions := strings.Join([]string{enumsOut, typesOut, operationsOut, allOfBoilerplate, unionBoilerplate, unionAndAdditionalBoilerplate}, "") + typeDefinitions := strings.Join([]string{enumsOut, constraintsOut, typesOut, operationsOut, allOfBoilerplate, unionBoilerplate, unionAndAdditionalBoilerplate}, "") return typeDefinitions, nil } @@ -860,6 +882,90 @@ func GenerateEnums(t *template.Template, types []TypeDefinition) (string, error) return GenerateTemplates([]string{"constants.tmpl"}, t, Constants{EnumDefinitions: enums}) } +// hasConstraints checks if a schema has any constraints worth generating constants for +func hasConstraints(schema *openapi3.Schema) bool { + if schema == nil { + return false + } + return schema.Min != nil || schema.Max != nil || + schema.MinLength > 0 || schema.MaxLength != nil || + schema.MinItems > 0 || schema.MaxItems != nil || + schema.Default != nil +} + +// GenerateConstraints generates constants for schema constraints (min, max, default, etc.) +func GenerateConstraints(t *template.Template, types []TypeDefinition) (string, error) { + var constraints []ConstraintDefinition + + // Keep track of which types we've processed + m := map[string]bool{} + + // Go through all types and extract constraint information + for _, tp := range types { + if found := m[tp.TypeName]; found { + continue + } + + m[tp.TypeName] = true + + // Skip if there are no constraints to generate + if tp.Schema.OAPISchema == nil { + continue + } + + schema := tp.Schema.OAPISchema + hasConstraints := false + constraint := ConstraintDefinition{ + TypeName: tp.TypeName, + Schema: tp.Schema, + } + + // Extract numeric constraints + if schema.Min != nil { + constraint.Minimum = schema.Min + constraint.ExclusiveMinimum = schema.ExclusiveMin + hasConstraints = true + } + if schema.Max != nil { + constraint.Maximum = schema.Max + constraint.ExclusiveMaximum = schema.ExclusiveMax + hasConstraints = true + } + + // Extract string constraints + if schema.MinLength > 0 { + constraint.MinLength = &schema.MinLength + hasConstraints = true + } + if schema.MaxLength != nil { + constraint.MaxLength = schema.MaxLength + hasConstraints = true + } + + // Extract array constraints + if schema.MinItems > 0 { + constraint.MinItems = &schema.MinItems + hasConstraints = true + } + if schema.MaxItems != nil { + constraint.MaxItems = schema.MaxItems + hasConstraints = true + } + + // Extract default value + if schema.Default != nil { + constraint.Default = schema.Default + hasConstraints = true + } + + if hasConstraints { + constraints = append(constraints, constraint) + } + } + + return GenerateTemplates([]string{"constants.tmpl"}, t, Constants{ConstraintDefinitions: constraints}) +} + // GenerateImports generates our import statements and package definition. func GenerateImports(t *template.Template, externalImports []string, packageName string, versionOverride *string) (string, error) { // Read build version for incorporating into generated files diff --git a/pkg/codegen/constraints_test.go b/pkg/codegen/constraints_test.go new file mode 100644 index 0000000000..87f5931938 --- /dev/null +++ b/pkg/codegen/constraints_test.go @@ -0,0 +1,357 @@ +package codegen + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const constraintsTestSpec = ` +openapi: 3.0.0 +info: + title: Constraints Test + version: 1.0.0 +components: + schemas: + # Number type with minimum and maximum + Age: + type: integer + minimum: 0 + maximum: 150 + default: 25 + + # Number with exclusive bounds + Temperature: + type: number + format: float + minimum: -273.15 + maximum: 1000.0 + exclusiveMinimum: true + exclusiveMaximum: false + + # String type with length constraints + Username: + type: string + minLength: 3 + maxLength: 20 + default: "user" + + # Array type with item constraints + Tags: + type: array + items: + type: string + minItems: 1 + maxItems: 10 + default: ["tag1"] + + # Boolean with default + IsActive: + type: boolean + default: true + + # Integer with various formats + Port: + type: integer + format: int32 + minimum: 1 + maximum: 65535 + default: 8080 + + # Number without constraints (should not generate constants) + Price: + type: number + + # String without constraints + Description: + type: string +` + +func TestGenerateConstraints(t *testing.T) { + loader := openapi3.NewLoader() + swagger, err := loader.LoadFromData([]byte(constraintsTestSpec)) + require.NoError(t, err) + + opts := Configuration{ + PackageName: "testconstraints", + Generate: GenerateOptions{ + Models: true, + }, + OutputOptions: OutputOptions{ + SkipPrune: true, + }, + } + + // Run full code generation which will include constraints + code, err := Generate(swagger, opts) + require.NoError(t, err) + + // Test Age constraints + assert.Contains(t, code, "AgeMinimum") + assert.Contains(t, code, "AgeMaximum") + assert.Contains(t, code, "AgeDefault") + assert.Contains(t, code, "int = 0") + assert.Contains(t, code, "int = 150") + assert.Contains(t, code, "int = 25") + + // Test Temperature constraints + assert.Contains(t, code, "TemperatureMinimum") + assert.Contains(t, code, "TemperatureMaximum") + assert.Contains(t, code, "float32 = -273.15") + assert.Contains(t, code, "float32 = 1000") + + // Test Username constraints + assert.Contains(t, code, "UsernameMinLength") + assert.Contains(t, code, "UsernameMaxLength") + assert.Contains(t, code, "UsernameDefault") + assert.Contains(t, code, "uint64 = 3") + assert.Contains(t, code, "uint64 = 20") + assert.Contains(t, code, `string = "user"`) + + // Test Tags constraints + assert.Contains(t, code, "TagsMinItems") + assert.Contains(t, code, "TagsMaxItems") + assert.Contains(t, code, "uint64 = 1") + assert.Contains(t, code, "uint64 = 10") + + // Test IsActive default + assert.Contains(t, code, "IsActiveDefault") + assert.Contains(t, code, "bool = true") + + // Test Port constraints + assert.Contains(t, code, "PortMinimum") + assert.Contains(t, code, "PortMaximum") + assert.Contains(t, code, "PortDefault") + assert.Contains(t, code, "int32 = 1") + assert.Contains(t, code, "int32 = 65535") + assert.Contains(t, code, "int32 = 8080") + + // Test that types without constraints don't generate constants + assert.NotContains(t, code, "PriceMinimum") + assert.NotContains(t, code, "PriceMaximum") + assert.NotContains(t, code, "DescriptionMinLength") +} + +func TestConstraintDefinitionExtraction(t *testing.T) { + tests := []struct { + name string + spec string + typeName string + wantMin *float64 + wantMax *float64 + wantDefault interface{} + wantMinLength *uint64 + wantMaxLength *uint64 + wantMinItems *uint64 + wantMaxItems *uint64 + }{ + { + name: "integer with all numeric constraints", + spec: ` +openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + TestInt: + type: integer + minimum: 10 + maximum: 100 + default: 50 +`, + typeName: "TestInt", + wantMin: ptrFloat64(10), + wantMax: ptrFloat64(100), + wantDefault: float64(50), + }, + { + name: "string with length constraints", + spec: ` +openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + TestString: + type: string + minLength: 5 + maxLength: 50 + default: "hello" +`, + typeName: "TestString", + wantMinLength: ptrUint64(5), + wantMaxLength: ptrUint64(50), + wantDefault: "hello", + }, + { + name: "array with item constraints", + spec: ` +openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + TestArray: + type: array + items: + type: string + minItems: 2 + maxItems: 20 +`, + typeName: "TestArray", + wantMinItems: ptrUint64(2), + wantMaxItems: ptrUint64(20), + }, + { + name: "boolean with default only", + spec: ` +openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + TestBool: + type: boolean + default: false +`, + typeName: "TestBool", + wantDefault: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loader := openapi3.NewLoader() + swagger, err := loader.LoadFromData([]byte(tt.spec)) + require.NoError(t, err) + + types, err := GenerateTypesForSchemas(nil, swagger.Components.Schemas, nil) + require.NoError(t, err) + require.Len(t, types, 1) + + tp := types[0] + require.Equal(t, tt.typeName, tp.TypeName) + require.NotNil(t, tp.Schema.OAPISchema) + + schema := tp.Schema.OAPISchema + + if tt.wantMin != nil { + require.NotNil(t, schema.Min) + assert.Equal(t, *tt.wantMin, *schema.Min) + } + + if tt.wantMax != nil { + require.NotNil(t, schema.Max) + assert.Equal(t, *tt.wantMax, *schema.Max) + } + + if tt.wantDefault != nil { + require.NotNil(t, schema.Default) + assert.Equal(t, tt.wantDefault, schema.Default) + } + + if tt.wantMinLength != nil { + assert.Equal(t, *tt.wantMinLength, schema.MinLength) + } + + if tt.wantMaxLength != nil { + require.NotNil(t, schema.MaxLength) + assert.Equal(t, *tt.wantMaxLength, *schema.MaxLength) + } + + if tt.wantMinItems != nil { + assert.Equal(t, *tt.wantMinItems, schema.MinItems) + } + + if tt.wantMaxItems != nil { + require.NotNil(t, schema.MaxItems) + assert.Equal(t, *tt.wantMaxItems, *schema.MaxItems) + } + }) + } +} + +func TestInlineParameterConstraints(t *testing.T) { + spec := ` +openapi: 3.0.0 +info: + title: Parameter Constraints Test + version: 1.0.0 +paths: + /users: + get: + operationId: getUsers + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - in: query + name: search + schema: + type: string + minLength: 3 + maxLength: 50 + - in: query + name: offset + schema: + type: integer + minimum: 0 + responses: + '200': + description: Success +` + + loader := openapi3.NewLoader() + swagger, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + opts := Configuration{ + PackageName: "testparams", + Generate: GenerateOptions{ + Models: true, + }, + OutputOptions: OutputOptions{ + SkipPrune: true, + }, + } + + code, err := Generate(swagger, opts) + require.NoError(t, err) + + // Test limit parameter constraints + assert.Contains(t, code, "GetUsersLimitMinimum") + assert.Contains(t, code, "GetUsersLimitMaximum") + assert.Contains(t, code, "GetUsersLimitDefault") + assert.Contains(t, code, "int = 1") + assert.Contains(t, code, "int = 100") + assert.Contains(t, code, "int = 20") + + // Test search parameter constraints + assert.Contains(t, code, "GetUsersSearchMinLength") + assert.Contains(t, code, "GetUsersSearchMaxLength") + assert.Contains(t, code, "uint64 = 3") + assert.Contains(t, code, "uint64 = 50") + + // Test offset parameter constraints (only minimum) + assert.Contains(t, code, "GetUsersOffsetMinimum") + assert.Contains(t, code, "int = 0") +} + +// Helper functions +func ptrFloat64(v float64) *float64 { + return &v +} + +func ptrUint64(v uint64) *uint64 { + return &v +} diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 7435fa224d..e2d0fffba6 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -197,11 +197,39 @@ func (e *EnumDefinition) GetValues() map[string]string { return newValues } +// ConstraintDefinition holds type information for a schema with constraints +type ConstraintDefinition struct { + // TypeName is the name of the type that has constraints + TypeName string + // Schema is the underlying schema + Schema Schema + // Minimum value for numbers + Minimum *float64 + // Maximum value for numbers + Maximum *float64 + // ExclusiveMinimum indicates if minimum is exclusive + ExclusiveMinimum bool + // ExclusiveMaximum indicates if maximum is exclusive + ExclusiveMaximum bool + // Default value + Default interface{} + // MinLength for strings + MinLength *uint64 + // MaxLength for strings + MaxLength *uint64 + // MinItems for arrays + MinItems *uint64 + // MaxItems for arrays + MaxItems *uint64 +} + type Constants struct { // SecuritySchemeProviderNames holds all provider names for security schemes. SecuritySchemeProviderNames []string // EnumDefinitions holds type and value information for all enums EnumDefinitions []EnumDefinition + // ConstraintDefinitions holds constraint information for types + ConstraintDefinitions []ConstraintDefinition } // TypeDefinition describes a Go type definition in generated code. diff --git a/pkg/codegen/templates/constants.tmpl b/pkg/codegen/templates/constants.tmpl index 8fad8764c4..64850e4e21 100644 --- a/pkg/codegen/templates/constants.tmpl +++ b/pkg/codegen/templates/constants.tmpl @@ -13,3 +13,43 @@ const ( {{end}} ) {{end}} +{{range $Constraint := .ConstraintDefinitions}} +{{- $hasSimpleDefault := false}} +{{- if $Constraint.Default}} +{{- if or (eq $Constraint.Schema.GoType "string") (eq $Constraint.Schema.GoType "bool") (eq $Constraint.Schema.GoType "int") (eq $Constraint.Schema.GoType "int32") (eq $Constraint.Schema.GoType "int64") (eq $Constraint.Schema.GoType "uint") (eq $Constraint.Schema.GoType "uint32") (eq $Constraint.Schema.GoType "uint64") (eq $Constraint.Schema.GoType "float32") (eq $Constraint.Schema.GoType "float64")}} +{{- $hasSimpleDefault = true}} +{{- end}} +{{- end}} +{{- if or $Constraint.Minimum $Constraint.Maximum $hasSimpleDefault $Constraint.MinLength $Constraint.MaxLength $Constraint.MinItems $Constraint.MaxItems}} +// Constraint constants for {{$Constraint.TypeName}}. +const ( +{{- if $Constraint.Minimum}} + {{$Constraint.TypeName}}Minimum {{$Constraint.Schema.GoType}} = {{$Constraint.Minimum}} +{{- end}} +{{- if $Constraint.Maximum}} + {{$Constraint.TypeName}}Maximum {{$Constraint.Schema.GoType}} = {{$Constraint.Maximum}} +{{- end}} +{{- if and $Constraint.Default $hasSimpleDefault}} + {{- if eq $Constraint.Schema.GoType "string"}} + {{$Constraint.TypeName}}Default {{$Constraint.Schema.GoType}} = "{{$Constraint.Default}}" + {{- else if eq $Constraint.Schema.GoType "bool"}} + {{$Constraint.TypeName}}Default {{$Constraint.Schema.GoType}} = {{$Constraint.Default}} + {{- else}} + {{$Constraint.TypeName}}Default {{$Constraint.Schema.GoType}} = {{$Constraint.Default}} + {{- end}} +{{- end}} +{{- if $Constraint.MinLength}} + {{$Constraint.TypeName}}MinLength uint64 = {{$Constraint.MinLength}} +{{- end}} +{{- if $Constraint.MaxLength}} + {{$Constraint.TypeName}}MaxLength uint64 = {{$Constraint.MaxLength}} +{{- end}} +{{- if $Constraint.MinItems}} + {{$Constraint.TypeName}}MinItems uint64 = {{$Constraint.MinItems}} +{{- end}} +{{- if $Constraint.MaxItems}} + {{$Constraint.TypeName}}MaxItems uint64 = {{$Constraint.MaxItems}} +{{- end}} +) +{{- end}} +{{end}}