diff --git a/README.md b/README.md index 15d75c4..2b51de4 100644 --- a/README.md +++ b/README.md @@ -322,4 +322,4 @@ Please check existing issues and discussions before starting work on new feature ## License -[MIT License](LICENSE) — Created with ❤️ by [Ahmad Faiz](https://github.com/akfaiz). \ No newline at end of file +[MIT](LICENSE) \ No newline at end of file diff --git a/adapter/README.md b/adapter/README.md new file mode 100644 index 0000000..50da103 --- /dev/null +++ b/adapter/README.md @@ -0,0 +1,17 @@ +# Framework Adapters + +This directory contains framework-specific adapters for `oaswrap/spec` that provide seamless integration with popular Go web frameworks. Each adapter automatically generates OpenAPI 3.x specifications from your existing routes and handlers. + +## Available Adapters + +### Web Frameworks + +| Framework | Adapter | Go Module | Description | +|-----------|---------|-----------|-------------| +| [Chi](https://github.com/go-chi/chi) | [`chiopenapi`](./chiopenapi) | `github.com/oaswrap/spec/adapter/chiopenapi` | Lightweight router with middleware support | +| [Echo](https://github.com/labstack/echo) | [`echoopenapi`](./echoopenapi) | `github.com/oaswrap/spec/adapter/echoopenapi` | High performance, extensible, minimalist framework | +| [Fiber](https://github.com/gofiber/fiber) | [`fiberopenapi`](./fiberopenapi) | `github.com/oaswrap/spec/adapter/fiberopenapi` | Express-inspired framework built on Fasthttp | +| [Gin](https://github.com/gin-gonic/gin) | [`ginopenapi`](./ginopenapi) | `github.com/oaswrap/spec/adapter/ginopenapi` | Fast HTTP web framework with zero allocation | +| [net/http](https://pkg.go.dev/net/http) | [`httpopenapi`](./httpopenapi) | `github.com/oaswrap/spec/adapter/httpopenapi` | Standard library HTTP package | +| [HttpRouter](https://github.com/julienschmidt/httprouter) | [`httprouteropenapi`](./httprouteropenapi) | `github.com/oaswrap/spec/adapter/httprouteropenapi` | High performance HTTP request router | +| [Gorilla Mux](https://github.com/gorilla/mux) | [`muxopenapi`](./muxopenapi) | `github.com/oaswrap/spec/adapter/muxopenapi` | Powerful HTTP router and URL matcher | \ No newline at end of file diff --git a/adapter/chiopenapi/README.md b/adapter/chiopenapi/README.md index 323e2a6..473eb9a 100644 --- a/adapter/chiopenapi/README.md +++ b/adapter/chiopenapi/README.md @@ -206,4 +206,4 @@ We welcome contributions! Please open issues and PRs at the main [oaswrap/spec]( ## License -[MIT License](LICENSE) — Created with ❤️ by [Ahmad Faiz](https://github.com/akfaiz). \ No newline at end of file +[MIT](../../LICENSE) \ No newline at end of file diff --git a/adapter/echoopenapi/README.MD b/adapter/echoopenapi/README.MD index 6a6fecb..452fc1e 100644 --- a/adapter/echoopenapi/README.MD +++ b/adapter/echoopenapi/README.MD @@ -77,7 +77,7 @@ type LoginResponse struct { } type GetUserRequest struct { - ID string `path:"id" required:"true"` + ID string `param:"id" required:"true"` } type User struct { @@ -194,4 +194,4 @@ We welcome contributions! Please open issues and PRs at the main [oaswrap/spec]( ## License -[MIT License](LICENSE) — Created with ❤️ by [Ahmad Faiz](https://github.com/akfaiz). \ No newline at end of file +[MIT](../../LICENSE) \ No newline at end of file diff --git a/adapter/echoopenapi/examples/basic/main.go b/adapter/echoopenapi/examples/basic/main.go index 088e81c..8205bad 100644 --- a/adapter/echoopenapi/examples/basic/main.go +++ b/adapter/echoopenapi/examples/basic/main.go @@ -52,7 +52,7 @@ type LoginResponse struct { } type GetUserRequest struct { - ID string `path:"id" required:"true"` + ID string `param:"id" required:"true"` } type User struct { diff --git a/adapter/echoopenapi/go.mod b/adapter/echoopenapi/go.mod index a08bd7e..18445b6 100644 --- a/adapter/echoopenapi/go.mod +++ b/adapter/echoopenapi/go.mod @@ -29,3 +29,5 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/oaswrap/spec => ../.. \ No newline at end of file diff --git a/adapter/echoopenapi/router.go b/adapter/echoopenapi/router.go index f5faa50..9aebdb4 100644 --- a/adapter/echoopenapi/router.go +++ b/adapter/echoopenapi/router.go @@ -7,6 +7,7 @@ import ( "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" "github.com/oaswrap/spec/adapter/echoopenapi/internal/constant" + "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" "github.com/oaswrap/spec/pkg/parser" @@ -36,6 +37,9 @@ func NewGenerator(e *echo.Echo, opts ...option.OpenAPIOption) Generator { option.WithPathParser(parser.NewColonParamParser()), option.WithStoplightElements(), option.WithCacheAge(0), + option.WithReflectorConfig( + option.ParameterTagMapping(openapi.ParameterInPath, "param"), + ), } opts = append(defaultOpts, opts...) gen := spec.NewRouter(opts...) diff --git a/adapter/echoopenapi/router_test.go b/adapter/echoopenapi/router_test.go index e98161d..31115ce 100644 --- a/adapter/echoopenapi/router_test.go +++ b/adapter/echoopenapi/router_test.go @@ -193,7 +193,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Get pet by ID"), option.Description("Retrieve a pet by its ID."), option.Request(new(struct { - ID int `path:"petId" required:"true"` + ID int `param:"petId" required:"true"` })), option.Response(200, new(dto.Pet)), ) @@ -226,7 +226,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Get order by ID"), option.Description("Retrieve an order by its ID."), option.Request(new(struct { - ID int `path:"orderId" required:"true"` + ID int `param:"orderId" required:"true"` })), option.Response(200, new(dto.Order)), option.Response(404, nil), @@ -236,7 +236,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Delete an order"), option.Description("Delete an order by its ID."), option.Request(new(struct { - ID int `path:"orderId" required:"true"` + ID int `param:"orderId" required:"true"` })), option.Response(204, nil), ) @@ -263,7 +263,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Get user by username"), option.Description("Retrieve a user by their username."), option.Request(new(struct { - Username string `path:"username" required:"true"` + Username string `param:"username" required:"true"` })), option.Response(200, new(dto.PetUser)), option.Response(404, nil), @@ -275,7 +275,7 @@ func TestRouter_Spec(t *testing.T) { option.Request(new(struct { dto.PetUser - Username string `path:"username" required:"true"` + Username string `param:"username" required:"true"` })), option.Response(200, new(dto.PetUser)), option.Response(404, nil), @@ -285,7 +285,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Delete a user"), option.Description("Delete a user from the store by their username."), option.Request(new(struct { - Username string `path:"username" required:"true"` + Username string `param:"username" required:"true"` })), option.Response(204, nil), ) diff --git a/adapter/fiberopenapi/README.md b/adapter/fiberopenapi/README.md index fb07b00..32f2b9e 100644 --- a/adapter/fiberopenapi/README.md +++ b/adapter/fiberopenapi/README.md @@ -75,7 +75,7 @@ type LoginResponse struct { } type GetUserRequest struct { - ID string `path:"id" required:"true"` + ID string `params:"id" required:"true"` } type User struct { @@ -189,4 +189,4 @@ We welcome contributions! Please open issues and PRs at the main [oaswrap/spec]( ## License -[MIT License](LICENSE) — Created with ❤️ by [Ahmad Faiz](https://github.com/akfaiz). \ No newline at end of file +[MIT](../../LICENSE) \ No newline at end of file diff --git a/adapter/fiberopenapi/examples/basic/main.go b/adapter/fiberopenapi/examples/basic/main.go index 2cc1214..0800439 100644 --- a/adapter/fiberopenapi/examples/basic/main.go +++ b/adapter/fiberopenapi/examples/basic/main.go @@ -50,7 +50,7 @@ type LoginResponse struct { } type GetUserRequest struct { - ID string `path:"id" required:"true"` + ID string `params:"id" required:"true"` } type User struct { diff --git a/adapter/fiberopenapi/go.mod b/adapter/fiberopenapi/go.mod index 44a9a88..40de55f 100644 --- a/adapter/fiberopenapi/go.mod +++ b/adapter/fiberopenapi/go.mod @@ -33,3 +33,5 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/oaswrap/spec => ../.. \ No newline at end of file diff --git a/adapter/fiberopenapi/router.go b/adapter/fiberopenapi/router.go index 197292a..756cb73 100644 --- a/adapter/fiberopenapi/router.go +++ b/adapter/fiberopenapi/router.go @@ -6,6 +6,7 @@ import ( "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" "github.com/oaswrap/spec/adapter/fiberopenapi/internal/constant" + "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" "github.com/oaswrap/spec/pkg/parser" @@ -29,6 +30,9 @@ func NewRouter(r fiber.Router, opts ...option.OpenAPIOption) Generator { option.WithPathParser(parser.NewColonParamParser()), option.WithStoplightElements(), option.WithCacheAge(0), + option.WithReflectorConfig( + option.ParameterTagMapping(openapi.ParameterInPath, "params"), + ), } opts = append(defaultOpts, opts...) gen := spec.NewGenerator(opts...) diff --git a/adapter/fiberopenapi/router_test.go b/adapter/fiberopenapi/router_test.go index e887817..1791218 100644 --- a/adapter/fiberopenapi/router_test.go +++ b/adapter/fiberopenapi/router_test.go @@ -133,7 +133,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Get pet by ID"), option.Description("Retrieve a pet by its ID."), option.Request(new(struct { - ID int `path:"petId" required:"true"` + ID int `params:"petId" required:"true"` })), option.Response(200, new(dto.Pet)), ) @@ -166,7 +166,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Get order by ID"), option.Description("Retrieve an order by its ID."), option.Request(new(struct { - ID int `path:"orderId" required:"true"` + ID int `params:"orderId" required:"true"` })), option.Response(200, new(dto.Order)), option.Response(404, nil), @@ -176,7 +176,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Delete an order"), option.Description("Delete an order by its ID."), option.Request(new(struct { - ID int `path:"orderId" required:"true"` + ID int `params:"orderId" required:"true"` })), option.Response(204, nil), ) @@ -203,7 +203,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Get user by username"), option.Description("Retrieve a user by their username."), option.Request(new(struct { - Username string `path:"username" required:"true"` + Username string `params:"username" required:"true"` })), option.Response(200, new(dto.PetUser)), option.Response(404, nil), @@ -215,7 +215,7 @@ func TestRouter_Spec(t *testing.T) { option.Request(new(struct { dto.PetUser - Username string `path:"username" required:"true"` + Username string `params:"username" required:"true"` })), option.Response(200, new(dto.PetUser)), option.Response(404, nil), @@ -225,7 +225,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Delete a user"), option.Description("Delete a user from the store by their username."), option.Request(new(struct { - Username string `path:"username" required:"true"` + Username string `params:"username" required:"true"` })), option.Response(204, nil), ) diff --git a/adapter/ginopenapi/README.md b/adapter/ginopenapi/README.md index 27583a3..ac8e5f4 100644 --- a/adapter/ginopenapi/README.md +++ b/adapter/ginopenapi/README.md @@ -193,4 +193,4 @@ We welcome contributions! Please open issues and PRs at the main [oaswrap/spec]( ## License -[MIT License](LICENSE) — Created with ❤️ by [Ahmad Faiz](https://github.com/akfaiz). \ No newline at end of file +[MIT](../../LICENSE) \ No newline at end of file diff --git a/adapter/ginopenapi/examples/basic/main.go b/adapter/ginopenapi/examples/basic/main.go index 9a8765d..22e53f1 100644 --- a/adapter/ginopenapi/examples/basic/main.go +++ b/adapter/ginopenapi/examples/basic/main.go @@ -50,7 +50,7 @@ type LoginResponse struct { } type GetUserRequest struct { - ID string `path:"id" uri:"id" required:"true"` + ID string `uri:"id" required:"true"` } type User struct { diff --git a/adapter/ginopenapi/go.mod b/adapter/ginopenapi/go.mod index 18d071a..53d9c59 100644 --- a/adapter/ginopenapi/go.mod +++ b/adapter/ginopenapi/go.mod @@ -47,3 +47,5 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/oaswrap/spec => ../.. \ No newline at end of file diff --git a/adapter/ginopenapi/router.go b/adapter/ginopenapi/router.go index 1ef90ec..5e517ad 100644 --- a/adapter/ginopenapi/router.go +++ b/adapter/ginopenapi/router.go @@ -7,6 +7,7 @@ import ( "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" "github.com/oaswrap/spec/adapter/ginopenapi/internal/constant" + "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" "github.com/oaswrap/spec/pkg/parser" @@ -30,6 +31,9 @@ func NewRouter(ginRouter gin.IRouter, opts ...option.OpenAPIOption) Generator { option.WithPathParser(parser.NewColonParamParser()), option.WithStoplightElements(), option.WithCacheAge(0), + option.WithReflectorConfig( + option.ParameterTagMapping(openapi.ParameterInPath, "uri"), + ), } opts = append(defaultOpts, opts...) gen := spec.NewRouter(opts...) diff --git a/adapter/ginopenapi/router_test.go b/adapter/ginopenapi/router_test.go index 5845202..f2418a9 100644 --- a/adapter/ginopenapi/router_test.go +++ b/adapter/ginopenapi/router_test.go @@ -131,7 +131,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Get pet by ID"), option.Description("Retrieve a pet by its ID."), option.Request(new(struct { - ID int `path:"petId" required:"true"` + ID int `uri:"petId" required:"true"` })), option.Response(200, new(dto.Pet)), ) @@ -164,7 +164,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Get order by ID"), option.Description("Retrieve an order by its ID."), option.Request(new(struct { - ID int `path:"orderId" required:"true"` + ID int `uri:"orderId" required:"true"` })), option.Response(200, new(dto.Order)), option.Response(404, nil), @@ -174,7 +174,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Delete an order"), option.Description("Delete an order by its ID."), option.Request(new(struct { - ID int `path:"orderId" required:"true"` + ID int `uri:"orderId" required:"true"` })), option.Response(204, nil), ) @@ -201,7 +201,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Get user by username"), option.Description("Retrieve a user by their username."), option.Request(new(struct { - Username string `path:"username" required:"true"` + Username string `uri:"username" required:"true"` })), option.Response(200, new(dto.PetUser)), option.Response(404, nil), @@ -213,7 +213,7 @@ func TestRouter_Spec(t *testing.T) { option.Request(new(struct { dto.PetUser - Username string `path:"username" required:"true"` + Username string `uri:"username" required:"true"` })), option.Response(200, new(dto.PetUser)), option.Response(404, nil), @@ -223,7 +223,7 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Delete a user"), option.Description("Delete a user from the store by their username."), option.Request(new(struct { - Username string `path:"username" required:"true"` + Username string `uri:"username" required:"true"` })), option.Response(204, nil), ) diff --git a/adapter/httpopenapi/README.md b/adapter/httpopenapi/README.md index 83c564e..9d38728 100644 --- a/adapter/httpopenapi/README.md +++ b/adapter/httpopenapi/README.md @@ -204,4 +204,4 @@ We welcome contributions! Please open issues and PRs at the main [oaswrap/spec]( ## License -[MIT License](LICENSE) — Created with ❤️ by [Ahmad Faiz](https://github.com/akfaiz). \ No newline at end of file +[MIT](../../LICENSE) \ No newline at end of file diff --git a/adapter/httprouteropenapi/README.md b/adapter/httprouteropenapi/README.md index 4bc20c2..9cc2313 100644 --- a/adapter/httprouteropenapi/README.md +++ b/adapter/httprouteropenapi/README.md @@ -197,4 +197,4 @@ We welcome contributions! Please open issues and PRs at the main [oaswrap/spec]( ## License -[MIT License](LICENSE) — Created with ❤️ by [Ahmad Faiz](https://github.com/akfaiz). \ No newline at end of file +[MIT](../../LICENSE) \ No newline at end of file diff --git a/adapter/httprouteropenapi/router.go b/adapter/httprouteropenapi/router.go index fcc06ca..e311e77 100644 --- a/adapter/httprouteropenapi/router.go +++ b/adapter/httprouteropenapi/router.go @@ -156,7 +156,7 @@ func (r *router) Group(prefix string, middlewares ...func(http.Handler) http.Han group := &router{ router: r.router, middlewares: append(r.middlewares, middlewares...), - specRouter: r.specRouter.Group(prefix), + specRouter: r.specRouter.Group(""), prefix: r.pathOf(prefix), } return group diff --git a/adapter/httprouteropenapi/testdata/petstore.yaml b/adapter/httprouteropenapi/testdata/petstore.yaml index dd71794..05fd9c5 100644 --- a/adapter/httprouteropenapi/testdata/petstore.yaml +++ b/adapter/httprouteropenapi/testdata/petstore.yaml @@ -28,7 +28,7 @@ tags: - description: Operations about user name: user paths: - /pet/pet: + /pet: post: description: Add a new pet to the store. operationId: addPet @@ -73,7 +73,7 @@ paths: summary: Update an existing pet tags: - pet - /pet/pet/{petId}: + /pet/{petId}: delete: description: Delete a pet from the store by its ID. operationId: deletePet @@ -121,7 +121,7 @@ paths: summary: Update pet with form tags: - pet - /pet/pet/{petId}/uploadImage: + /pet/{petId}/uploadImage: post: description: Uploads an image for a pet. operationId: uploadFile @@ -155,7 +155,7 @@ paths: summary: Upload an image for a pet tags: - pet - /pet/pet/findByStatus: + /pet/findByStatus: get: description: Finds Pets by status. Multiple status values can be provided with comma separated strings. @@ -185,7 +185,7 @@ paths: summary: Find pets by status tags: - pet - /pet/pet/findByTags: + /pet/findByTags: get: description: Finds Pets by tags. Multiple tags can be provided with comma separated strings. @@ -213,7 +213,7 @@ paths: summary: Find pets by tags tags: - pet - /pet/pet/id/{petId}: + /pet/id/{petId}: get: description: Retrieve a pet by its ID. operationId: getPetById @@ -237,7 +237,7 @@ paths: summary: Get pet by ID tags: - pet - /store/store/order: + /store/order: post: description: Place a new order for a pet. operationId: placeOrder @@ -256,7 +256,7 @@ paths: summary: Place an order tags: - store - /store/store/order/{orderId}: + /store/order/{orderId}: delete: description: Delete an order by its ID. operationId: deleteOrder @@ -293,7 +293,7 @@ paths: summary: Get order by ID tags: - store - /user/user: + /user: post: description: Create a new user in the store. operationId: createUser @@ -312,7 +312,7 @@ paths: summary: Create a new user tags: - user - /user/user/{username}: + /user/{username}: delete: description: Delete a user from the store by their username. operationId: deleteUser @@ -396,7 +396,7 @@ paths: summary: Update an existing user tags: - user - /user/user/createWithList: + /user/createWithList: post: description: Create multiple users in the store with a list. operationId: createUsersWithList diff --git a/adapter/muxopenapi/README.md b/adapter/muxopenapi/README.md index f3918cc..6053646 100644 --- a/adapter/muxopenapi/README.md +++ b/adapter/muxopenapi/README.md @@ -208,4 +208,4 @@ We welcome contributions! Please open issues and PRs at the main [oaswrap/spec]( ## License -[MIT License](LICENSE) — Created with ❤️ by [Ahmad Faiz](https://github.com/akfaiz). \ No newline at end of file +[MIT](../../LICENSE) \ No newline at end of file diff --git a/openapi/config.go b/openapi/config.go index e17bee8..12cd44a 100644 --- a/openapi/config.go +++ b/openapi/config.go @@ -40,14 +40,15 @@ type Config struct { // ReflectorConfig holds advanced options for schema reflection. type ReflectorConfig struct { - InlineRefs bool // If true, inline schema references instead of using components. - RootRef bool // If true, use a root reference for top-level schemas. - RootNullable bool // If true, allow root schemas to be nullable. - StripDefNamePrefix []string // Prefixes to strip from generated definition names. - InterceptDefNameFunc InterceptDefNameFunc // Function to customize definition names. - InterceptPropFunc InterceptPropFunc // Function to intercept property schema generation. - InterceptSchemaFunc InterceptSchemaFunc // Function to intercept full schema generation. - TypeMappings []TypeMapping // Custom type mappings for schema generation. + InlineRefs bool // If true, inline schema references instead of using components. + RootRef bool // If true, use a root reference for top-level schemas. + RootNullable bool // If true, allow root schemas to be nullable. + StripDefNamePrefix []string // Prefixes to strip from generated definition names. + InterceptDefNameFunc InterceptDefNameFunc // Function to customize definition names. + InterceptPropFunc InterceptPropFunc // Function to intercept property schema generation. + InterceptSchemaFunc InterceptSchemaFunc // Function to intercept full schema generation. + TypeMappings []TypeMapping // Custom type mappings for schema generation. + ParameterTagMapping map[ParameterIn]string // Custom struct tag mapping for parameters. } // TypeMapping maps a source type to a target type in schema generation. diff --git a/openapi/entities.go b/openapi/entities.go index 9b658a5..d520c34 100644 --- a/openapi/entities.go +++ b/openapi/entities.go @@ -171,3 +171,14 @@ type OAuthFlowsAuthorizationCode struct { MapOfAnything map[string]any // Vendor extensions. Keys must match `^x-`. } + +// ParameterIn is an enum type. +type ParameterIn string + +// ParameterIn values enumeration. +const ( + ParameterInPath = ParameterIn("path") + ParameterInQuery = ParameterIn("query") + ParameterInHeader = ParameterIn("header") + ParameterInCookie = ParameterIn("cookie") +) diff --git a/operation.go b/operation.go index b6089c1..720d593 100644 --- a/operation.go +++ b/operation.go @@ -2,6 +2,7 @@ package spec import ( "fmt" + "reflect" "strings" "github.com/oaswrap/spec/internal/debuglog" @@ -15,9 +16,10 @@ import ( var _ operationContext = (*operationContextImpl)(nil) type operationContextImpl struct { - op openapi.OperationContext - cfg *option.OperationConfig - logger *debuglog.Logger + op openapi.OperationContext + cfg *option.OperationConfig + logger *debuglog.Logger + parameterTagMapping map[specopenapi.ParameterIn]string } func (oc *operationContextImpl) With(opts ...option.OperationOption) operationContext { @@ -70,7 +72,7 @@ func (oc *operationContextImpl) build() openapi.OperationContext { for _, req := range cfg.Requests { opts, value := oc.buildRequestOpts(req) - oc.op.AddReqStructure(req.Structure, opts...) + oc.op.AddReqStructure(oc.modifyReqStructure(req.Structure), opts...) logger.LogOp(method, path, "add request", value) } @@ -164,3 +166,87 @@ func (oc *operationContextImpl) buildResponseOpts(resp *specopenapi.ContentUnit) } return opts, log } + +func (oc *operationContextImpl) modifyReqStructure(structure any) any { + if len(oc.parameterTagMapping) == 0 { + return structure + } + + t := reflect.TypeOf(structure) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + // Only structs are supported for parameter tag modification + if t.Kind() != reflect.Struct { + return structure + } + + fields, modified := oc.buildModifiedFields(t) + if !modified { + return structure + } + + // Create new struct type with modified fields + newType := reflect.StructOf(fields) + return reflect.New(newType).Interface() +} + +// buildModifiedFields processes struct fields and applies parameter tag mappings. +func (oc *operationContextImpl) buildModifiedFields(t reflect.Type) ([]reflect.StructField, bool) { + fields := make([]reflect.StructField, 0, t.NumField()) + modified := false + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + originalField := field + + // Apply parameter tag mappings + for paramIn, sourceTag := range oc.parameterTagMapping { + if oc.shouldApplyMapping(field, sourceTag, string(paramIn)) { + field.Tag = oc.buildNewTag(field.Tag, sourceTag, string(paramIn)) + modified = true + } + } + + fields = append(fields, field) + + // Log if field was modified (for debugging) + if field.Tag != originalField.Tag { + oc.logger.LogAction("modified field tag", + fmt.Sprintf("field=%s, original=%q, new=%q", + field.Name, originalField.Tag, field.Tag)) + } + } + + return fields, modified +} + +// shouldApplyMapping determines if a parameter tag mapping should be applied to a field. +func (oc *operationContextImpl) shouldApplyMapping(field reflect.StructField, sourceTag, targetTag string) bool { + // Only apply if source tag exists and target tag doesn't exist + return field.Tag.Get(sourceTag) != "" && field.Tag.Get(targetTag) == "" +} + +// buildNewTag constructs a new struct tag by adding the mapped parameter tag. +func (oc *operationContextImpl) buildNewTag( + originalTag reflect.StructTag, + sourceTag, targetTag string, +) reflect.StructTag { + sourceValue := originalTag.Get(sourceTag) + if sourceValue == "" { + return originalTag + } + + // Parse existing tag string and add new tag + tagStr := string(originalTag) + if tagStr != "" && !strings.HasSuffix(tagStr, " ") { + tagStr += " " + } + + // Escape quotes in the tag value + escapedValue := strings.ReplaceAll(sourceValue, `"`, `\"`) + newTag := fmt.Sprintf(`%s%s:"%s"`, tagStr, targetTag, escapedValue) + + return reflect.StructTag(newTag) +} diff --git a/option/reflector.go b/option/reflector.go index c676f3e..e163fc9 100644 --- a/option/reflector.go +++ b/option/reflector.go @@ -126,3 +126,17 @@ func TypeMapping(src, dst any) ReflectorOption { }) } } + +// ParameterTagMapping sets a custom struct tag mapping for parameters of a specific location. +// +// Example: +// +// option.WithReflectorConfig(option.ParameterTagMapping(openapi.ParameterInPath, "param")) +func ParameterTagMapping(paramIn openapi.ParameterIn, tagName string) ReflectorOption { + return func(c *openapi.ReflectorConfig) { + if c.ParameterTagMapping == nil { + c.ParameterTagMapping = make(map[openapi.ParameterIn]string) + } + c.ParameterTagMapping[paramIn] = tagName + } +} diff --git a/option/reflector_test.go b/option/reflector_test.go index d7a6827..2c8171a 100644 --- a/option/reflector_test.go +++ b/option/reflector_test.go @@ -112,3 +112,11 @@ func TestTypeMapping(t *testing.T) { assert.Equal(t, src, mapping.Src) assert.Equal(t, dst, mapping.Dst) } + +func TestParameterTagMapping(t *testing.T) { + config := &openapi.ReflectorConfig{} + opt := option.ParameterTagMapping(openapi.ParameterInPath, "param") + opt(config) + + assert.Equal(t, "param", config.ParameterTagMapping[openapi.ParameterInPath]) +} diff --git a/reflector3.go b/reflector3.go index 7080999..4960cf7 100644 --- a/reflector3.go +++ b/reflector3.go @@ -13,10 +13,11 @@ import ( ) type reflector3 struct { - reflector *openapi3.Reflector - logger *debuglog.Logger - errors *errs.SpecError - pathParser openapi.PathParser + reflector *openapi3.Reflector + logger *debuglog.Logger + errors *errs.SpecError + pathParser openapi.PathParser + parameterTagMapping map[openapi.ParameterIn]string } func newReflector3(cfg *openapi.Config, logger *debuglog.Logger) reflector { @@ -86,6 +87,8 @@ func newReflector3(cfg *openapi.Config, logger *debuglog.Logger) reflector { } } + var parameterTagMapping map[openapi.ParameterIn]string + // Custom options for JSON schema generation if cfg.ReflectorConfig != nil { jsonSchemaOpts := getJSONSchemaOpts(cfg.ReflectorConfig, logger) @@ -97,13 +100,16 @@ func newReflector3(cfg *openapi.Config, logger *debuglog.Logger) reflector { reflector.AddTypeMapping(opt.Src, opt.Dst) logger.LogAction("add type mapping", fmt.Sprintf("%T -> %T", opt.Src, opt.Dst)) } + + parameterTagMapping = cfg.ReflectorConfig.ParameterTagMapping } return &reflector3{ - reflector: reflector, - logger: logger, - errors: &errs.SpecError{}, - pathParser: cfg.PathParser, + reflector: reflector, + logger: logger, + errors: &errs.SpecError{}, + pathParser: cfg.PathParser, + parameterTagMapping: parameterTagMapping, } } @@ -159,8 +165,9 @@ func (r *reflector3) newOperationContext(method, path string) (operationContext, return nil, err } return &operationContextImpl{ - op: op, - logger: r.logger, - cfg: &option.OperationConfig{}, + op: op, + logger: r.logger, + cfg: &option.OperationConfig{}, + parameterTagMapping: r.parameterTagMapping, }, nil } diff --git a/reflector31.go b/reflector31.go index 5c2d587..a5644cc 100644 --- a/reflector31.go +++ b/reflector31.go @@ -13,10 +13,11 @@ import ( ) type reflector31 struct { - reflector *openapi31.Reflector - logger *debuglog.Logger - pathParser openapi.PathParser - errors *errs.SpecError + reflector *openapi31.Reflector + logger *debuglog.Logger + pathParser openapi.PathParser + parameterTagMapping map[openapi.ParameterIn]string + errors *errs.SpecError } func newReflector31(cfg *openapi.Config, logger *debuglog.Logger) reflector { @@ -83,6 +84,8 @@ func newReflector31(cfg *openapi.Config, logger *debuglog.Logger) reflector { } } + var parameterTagMapping map[openapi.ParameterIn]string + // Custom options for JSON schema generation if cfg.ReflectorConfig != nil { jsonSchemaOpts := getJSONSchemaOpts(cfg.ReflectorConfig, logger) @@ -94,13 +97,16 @@ func newReflector31(cfg *openapi.Config, logger *debuglog.Logger) reflector { reflector.AddTypeMapping(opt.Src, opt.Dst) logger.LogAction("add type mapping", fmt.Sprintf("%T -> %T", opt.Src, opt.Dst)) } + + parameterTagMapping = cfg.ReflectorConfig.ParameterTagMapping } return &reflector31{ - reflector: reflector, - logger: logger, - errors: &errs.SpecError{}, - pathParser: cfg.PathParser, + reflector: reflector, + logger: logger, + errors: &errs.SpecError{}, + pathParser: cfg.PathParser, + parameterTagMapping: parameterTagMapping, } } @@ -156,8 +162,9 @@ func (r *reflector31) newOperationContext(method, path string) (operationContext return nil, err } return &operationContextImpl{ - op: op, - logger: r.logger, - cfg: &option.OperationConfig{}, + op: op, + logger: r.logger, + cfg: &option.OperationConfig{}, + parameterTagMapping: r.parameterTagMapping, }, nil } diff --git a/router_test.go b/router_test.go index efca0b5..69c81b1 100644 --- a/router_test.go +++ b/router_test.go @@ -236,6 +236,29 @@ func TestRouter(t *testing.T) { ) }, }, + { + name: "Custom Parameter Mapping", + golden: "custom_parameter_mapping", + opts: []option.OpenAPIOption{ + option.WithReflectorConfig( + option.ParameterTagMapping(openapi.ParameterInPath, "param"), + option.ParameterTagMapping(openapi.ParameterInQuery, "query2"), + ), + }, + setup: func(r spec.Router) { + type GetUserByIDRequest struct { + ID int `param:"id"` + ExtraParam string ` query2:"extra_param" required:"true"` + } + r.Get("/user/{id}", + option.OperationID("getUserById"), + option.Summary("Get User by ID"), + option.Description("This operation retrieves a user by ID."), + option.Request(new(GetUserByIDRequest)), + option.Response(200, new(User)), + ) + }, + }, { name: "Pet Store", golden: "petstore", @@ -577,7 +600,7 @@ func TestRouter(t *testing.T) { option.Summary("Get User by ID"), option.Description("This operation retrieves a user by ID."), option.Request(new(struct { - ID int `path:"id" validate:"required"` + ID int `path:"id"` })), option.Response(200, new(User)), ) @@ -661,7 +684,7 @@ func TestRouter(t *testing.T) { option.Summary("Get User by ID"), option.Description("This operation retrieves a user by ID."), option.Request(new(struct { - ID int `params:"id" validate:"required"` + ID int `params:"id"` })), ) }, @@ -678,7 +701,7 @@ func TestRouter(t *testing.T) { option.Summary("Get User by ID"), option.Description("This operation retrieves a user by ID."), option.Request(new(struct { - ID int `path:"id" validate:"required"` + ID int `path:"id"` })), option.Response(200, new(User)), ) diff --git a/testdata/custom_parameter_mapping_3.yaml b/testdata/custom_parameter_mapping_3.yaml new file mode 100644 index 0000000..04d3108 --- /dev/null +++ b/testdata/custom_parameter_mapping_3.yaml @@ -0,0 +1,52 @@ +openapi: 3.0.3 +info: + description: This is the API documentation for Custom Parameter Mapping + title: 'API Doc: Custom Parameter Mapping' + version: 1.0.0 +paths: + /user/{id}: + get: + description: This operation retrieves a user by ID. + operationId: getUserById + parameters: + - in: query + name: extra_param + required: true + schema: + type: string + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SpecTestUser' + description: OK + summary: Get User by ID +components: + schemas: + SpecTestNullString: + type: object + SpecTestNullTime: + type: object + SpecTestUser: + properties: + age: + nullable: true + type: integer + created_at: + format: date-time + type: string + email: + $ref: '#/components/schemas/SpecTestNullString' + id: + type: integer + updated_at: + $ref: '#/components/schemas/SpecTestNullTime' + username: + type: string + type: object diff --git a/testdata/custom_parameter_mapping_31.yaml b/testdata/custom_parameter_mapping_31.yaml new file mode 100644 index 0000000..f276826 --- /dev/null +++ b/testdata/custom_parameter_mapping_31.yaml @@ -0,0 +1,53 @@ +openapi: 3.1.0 +info: + description: This is the API documentation for Custom Parameter Mapping + title: 'API Doc: Custom Parameter Mapping' + version: 1.0.0 +paths: + /user/{id}: + get: + description: This operation retrieves a user by ID. + operationId: getUserById + parameters: + - in: query + name: extra_param + required: true + schema: + type: string + - in: path + name: id + required: true + schema: + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/SpecTestUser' + description: OK + summary: Get User by ID +components: + schemas: + SpecTestNullString: + type: object + SpecTestNullTime: + type: object + SpecTestUser: + properties: + age: + type: + - "null" + - integer + created_at: + format: date-time + type: string + email: + $ref: '#/components/schemas/SpecTestNullString' + id: + type: integer + updated_at: + $ref: '#/components/schemas/SpecTestNullTime' + username: + type: string + type: object