From 321df40daab2d7b7271be668a82c6cdd94ed0aad Mon Sep 17 00:00:00 2001 From: Suhaibinator <42899065+Suhaibinator@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:40:27 -0700 Subject: [PATCH] Add route export spec generation and metadata capture --- pkg/codec/codec.go | 3 + pkg/codec/json.go | 5 + pkg/codec/proto.go | 5 + pkg/router/cors_test.go | 4 + pkg/router/export.go | 238 +++++++++++++++++++++++++++++++++++++ pkg/router/export_test.go | 116 ++++++++++++++++++ pkg/router/reflect.go | 94 +++++++++++++++ pkg/router/reflect_test.go | 43 +++++++ pkg/router/route.go | 56 ++++++++- pkg/router/route_test.go | 4 + pkg/router/router.go | 18 ++- 11 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 pkg/router/export.go create mode 100644 pkg/router/export_test.go create mode 100644 pkg/router/reflect.go create mode 100644 pkg/router/reflect_test.go diff --git a/pkg/codec/codec.go b/pkg/codec/codec.go index c6b9270..2d6dae4 100644 --- a/pkg/codec/codec.go +++ b/pkg/codec/codec.go @@ -7,6 +7,9 @@ import "net/http" // response data. This allows for different data formats (e.g., JSON, Protocol Buffers). // The framework includes implementations for JSON and Protocol Buffers in the codec package. type Codec[T any, U any] interface { + // Name returns codec identifier for metadata export ("json", "proto", ...). + Name() string + // NewRequest creates a new zero-value instance of the request type T. // This is used by the framework to get an instance for decoding, avoiding reflection. NewRequest() T diff --git a/pkg/codec/json.go b/pkg/codec/json.go index dedc36d..c33ec3e 100644 --- a/pkg/codec/json.go +++ b/pkg/codec/json.go @@ -14,6 +14,11 @@ type JSONCodec[T any, U any] struct { // For example, custom field naming strategies, etc. } +// Name returns codec identifier. +func (c *JSONCodec[T, U]) Name() string { + return "json" +} + // NewRequest creates a new zero-value instance of the request type T. // This method is required by the Codec interface and is used internally // by the framework to get an instance for decoding without using reflection. diff --git a/pkg/codec/proto.go b/pkg/codec/proto.go index e247e94..521fa35 100644 --- a/pkg/codec/proto.go +++ b/pkg/codec/proto.go @@ -15,6 +15,11 @@ type ProtoCodec[T proto.Message, U proto.Message] struct { newRequest func() T } +// Name returns codec identifier. +func (c *ProtoCodec[T, U]) Name() string { + return "proto" +} + // NewProtoCodec creates a new ProtoCodec instance for protobuf request/response types. // It infers the underlying message type from T and allocates fresh zero-value messages // without reflection by using Go's new(expr) support. diff --git a/pkg/router/cors_test.go b/pkg/router/cors_test.go index 6a15197..2c7f4ab 100644 --- a/pkg/router/cors_test.go +++ b/pkg/router/cors_test.go @@ -24,6 +24,10 @@ type genericCORSTestResponse struct { // genericCORSTestCodec implements the Codec interface for testing generic routes with CORS. type genericCORSTestCodec struct{} +func (c *genericCORSTestCodec) Name() string { + return "json" +} + func (c *genericCORSTestCodec) NewRequest() genericCORSTestRequest { return genericCORSTestRequest{} } diff --git a/pkg/router/export.go b/pkg/router/export.go new file mode 100644 index 0000000..980fcde --- /dev/null +++ b/pkg/router/export.go @@ -0,0 +1,238 @@ +package router + +import ( + "encoding/json" + "io" + "os" + "time" + + "github.com/Suhaibinator/SRouter/pkg/common" +) + +const exportSpecVersion = "1.0" + +// ExportSpec describes the exported router metadata document. +type ExportSpec struct { + Version string `json:"version" yaml:"version"` + ExportedAt string `json:"exportedAt" yaml:"exportedAt"` + Service ServiceMetadata `json:"service" yaml:"service"` + Routes []RouteMetadata `json:"routes" yaml:"routes"` +} + +// ServiceMetadata captures service-wide router configuration. +type ServiceMetadata struct { + Name string `json:"name" yaml:"name"` + GlobalTimeout string `json:"globalTimeout,omitempty" yaml:"globalTimeout,omitempty"` + GlobalMaxBody int64 `json:"globalMaxBodySize,omitempty" yaml:"globalMaxBodySize,omitempty"` + GlobalRateLimit *RateLimitMetadata `json:"globalRateLimit,omitempty" yaml:"globalRateLimit,omitempty"` + CORS *CORSMetadata `json:"cors,omitempty" yaml:"cors,omitempty"` + TraceIDEnabled bool `json:"traceIdEnabled" yaml:"traceIdEnabled"` +} + +// CORSMetadata captures CORS settings. +type CORSMetadata struct { + Origins []string `json:"origins" yaml:"origins"` + Methods []string `json:"methods" yaml:"methods"` + Headers []string `json:"headers" yaml:"headers"` + ExposeHeaders []string `json:"exposeHeaders,omitempty" yaml:"exposeHeaders,omitempty"` + AllowCredentials bool `json:"allowCredentials" yaml:"allowCredentials"` + MaxAge string `json:"maxAge,omitempty" yaml:"maxAge,omitempty"` +} + +// RouteMetadata describes a registered route. +type RouteMetadata struct { + Path string `json:"path" yaml:"path"` + Methods []string `json:"methods" yaml:"methods"` + AuthLevel string `json:"authLevel" yaml:"authLevel"` + Request *RequestSchema `json:"request,omitempty" yaml:"request,omitempty"` + Response *TypeSchema `json:"response,omitempty" yaml:"response,omitempty"` + Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"` + MaxBodySize int64 `json:"maxBodySize,omitempty" yaml:"maxBodySize,omitempty"` + RateLimit *RateLimitMetadata `json:"rateLimit,omitempty" yaml:"rateLimit,omitempty"` + AuthToken *AuthTokenMetadata `json:"authToken,omitempty" yaml:"authToken,omitempty"` + SubRouter string `json:"subRouter,omitempty" yaml:"subRouter,omitempty"` + DisableTimeout bool `json:"disableTimeout,omitempty" yaml:"disableTimeout,omitempty"` +} + +// RequestSchema describes generic route request extraction and shape. +type RequestSchema struct { + Source string `json:"source" yaml:"source"` + SourceKey string `json:"sourceKey,omitempty" yaml:"sourceKey,omitempty"` + Codec string `json:"codec" yaml:"codec"` + Schema *TypeSchema `json:"schema" yaml:"schema"` + HasSanitizer bool `json:"hasSanitizer,omitempty" yaml:"hasSanitizer,omitempty"` +} + +// TypeSchema describes a Go type tree. +type TypeSchema struct { + TypeName string `json:"typeName" yaml:"typeName"` + Package string `json:"package,omitempty" yaml:"package,omitempty"` + Kind string `json:"kind" yaml:"kind"` + Fields []FieldSchema `json:"fields,omitempty" yaml:"fields,omitempty"` +} + +// FieldSchema describes a reflected struct field. +type FieldSchema struct { + Name string `json:"name" yaml:"name"` + JSONName string `json:"jsonName,omitempty" yaml:"jsonName,omitempty"` + Type string `json:"type" yaml:"type"` + Required bool `json:"required,omitempty" yaml:"required,omitempty"` + Schema *TypeSchema `json:"schema,omitempty" yaml:"schema,omitempty"` +} + +// RateLimitMetadata describes exported rate limiting config. +type RateLimitMetadata struct { + BucketName string `json:"bucketName,omitempty" yaml:"bucketName,omitempty"` + Limit int `json:"limit" yaml:"limit"` + Window string `json:"window" yaml:"window"` + Strategy string `json:"strategy" yaml:"strategy"` +} + +// AuthTokenMetadata describes auth token extraction settings. +type AuthTokenMetadata struct { + Source string `json:"source" yaml:"source"` + HeaderName string `json:"headerName,omitempty" yaml:"headerName,omitempty"` + CookieName string `json:"cookieName,omitempty" yaml:"cookieName,omitempty"` +} + +// ExportSpec returns a snapshot export document for the router. +func (r *Router[T, U]) ExportSpec() *ExportSpec { + service := ServiceMetadata{ + Name: r.config.ServiceName, + GlobalTimeout: durationString(r.config.GlobalTimeout), + GlobalMaxBody: r.config.GlobalMaxBodySize, + GlobalRateLimit: rateLimitMetadataFromConfig(r.config.GlobalRateLimit), + TraceIDEnabled: r.config.TraceIDBufferSize > 0, + } + if r.config.CORSConfig != nil { + service.CORS = &CORSMetadata{ + Origins: append([]string(nil), r.config.CORSConfig.Origins...), + Methods: append([]string(nil), r.config.CORSConfig.Methods...), + Headers: append([]string(nil), r.config.CORSConfig.Headers...), + ExposeHeaders: append([]string(nil), r.config.CORSConfig.ExposeHeaders...), + AllowCredentials: r.config.CORSConfig.AllowCredentials, + MaxAge: durationString(r.config.CORSConfig.MaxAge), + } + } + + r.metadataMu.RLock() + routes := append([]RouteMetadata(nil), r.routeMetadata...) + r.metadataMu.RUnlock() + + return &ExportSpec{ + Version: exportSpecVersion, + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Service: service, + Routes: routes, + } +} + +// ExportJSON writes the spec as indented JSON. +func (r *Router[T, U]) ExportJSON(w io.Writer) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(r.ExportSpec()) +} + +// ExportJSONFile writes the spec to a file path as indented JSON. +func (r *Router[T, U]) ExportJSONFile(path string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + return r.ExportJSON(f) +} + +func (r *Router[T, U]) appendRouteMetadata(metadata RouteMetadata) { + r.metadataMu.Lock() + r.routeMetadata = append(r.routeMetadata, metadata) + r.metadataMu.Unlock() +} + +func durationString(d time.Duration) string { + if d <= 0 { + return "" + } + return d.String() +} + +func authLevelString(level *AuthLevel) string { + if level == nil { + return "none" + } + switch *level { + case AuthRequired: + return "required" + case AuthOptional: + return "optional" + default: + return "none" + } +} + +func sourceTypeString(source SourceType) string { + switch source { + case Body: + return "body" + case Base64QueryParameter: + return "base64_query" + case Base62QueryParameter: + return "base62_query" + case Base64PathParameter: + return "base64_path" + case Base62PathParameter: + return "base62_path" + case Empty: + return "empty" + default: + return "unknown" + } +} + +func rateLimitStrategyString(strategy common.RateLimitStrategy) string { + switch strategy { + case common.StrategyUser: + return "user" + case common.StrategyCustom: + return "custom" + default: + return "ip" + } +} + +func rateLimitMetadataFromConfig(config *common.RateLimitConfig[any, any]) *RateLimitMetadata { + if config == nil { + return nil + } + return &RateLimitMetadata{ + BucketName: config.BucketName, + Limit: config.Limit, + Window: durationString(config.Window), + Strategy: rateLimitStrategyString(config.Strategy), + } +} + +func rateLimitMetadataFromRuntimeConfig[T comparable, U any](config *common.RateLimitConfig[T, U]) *RateLimitMetadata { + if config == nil { + return nil + } + return &RateLimitMetadata{ + BucketName: config.BucketName, + Limit: config.Limit, + Window: durationString(config.Window), + Strategy: rateLimitStrategyString(config.Strategy), + } +} + +func authTokenMetadataFromConfig(config common.AuthTokenConfig) *AuthTokenMetadata { + source := "header" + if config.Source == common.AuthTokenSourceCookie { + source = "cookie" + } + return &AuthTokenMetadata{ + Source: source, + HeaderName: config.HeaderName, + CookieName: config.CookieName, + } +} diff --git a/pkg/router/export_test.go b/pkg/router/export_test.go new file mode 100644 index 0000000..8c98c66 --- /dev/null +++ b/pkg/router/export_test.go @@ -0,0 +1,116 @@ +package router + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Suhaibinator/SRouter/pkg/codec" + "github.com/Suhaibinator/SRouter/pkg/common" +) + +type exportReq struct { + Query string `json:"query"` +} + +type exportResp struct { + Result string `json:"result"` +} + +func TestExportSpecIncludesRegisteredRoutes(t *testing.T) { + subAuth := AuthRequired + cfg := RouterConfig{ + ServiceName: "export-test", + GlobalTimeout: 5 * time.Second, + GlobalMaxBodySize: 1024, + CORSConfig: &CORSConfig{ + Origins: []string{"https://example.com"}, + Methods: []string{"GET", "POST"}, + Headers: []string{"Content-Type"}, + MaxAge: 24 * time.Hour, + }, + SubRouters: []SubRouterConfig{ + { + PathPrefix: "/api", + AuthLevel: &subAuth, + Routes: []RouteDefinition{ + RouteConfigBase{ + Path: "/health", + Methods: []HttpMethod{MethodGet}, + Handler: func(http.ResponseWriter, *http.Request) {}, + }, + NewGenericRouteDefinition[exportReq, exportResp, string, string](RouteConfig[exportReq, exportResp]{ + Path: "/search", + Methods: []HttpMethod{MethodPost}, + Codec: codec.NewJSONCodec[exportReq, exportResp](), + SourceType: Body, + Handler: func(r *http.Request, data exportReq) (exportResp, error) { + return exportResp{Result: data.Query}, nil + }, + }), + }, + }, + }, + } + + r := NewRouter[string, string](cfg, func(context.Context, string) (*string, bool) { return nil, false }, func(*string) string { return "" }) + spec := r.ExportSpec() + if spec.Version != exportSpecVersion { + t.Fatalf("unexpected version: %q", spec.Version) + } + if len(spec.Routes) != 2 { + t.Fatalf("expected 2 routes, got %d", len(spec.Routes)) + } + if spec.Service.Name != "export-test" { + t.Fatalf("unexpected service name: %q", spec.Service.Name) + } + + var foundGeneric bool + for _, route := range spec.Routes { + if route.Path == "/api/search" { + foundGeneric = true + if route.Request == nil || route.Request.Codec != "json" { + t.Fatalf("expected generic request schema with json codec, got: %+v", route.Request) + } + if route.Response == nil || route.Response.Kind != "struct" { + t.Fatalf("expected response schema, got: %+v", route.Response) + } + } + } + if !foundGeneric { + t.Fatal("expected to find /api/search route in export") + } +} + +func TestExportJSONFile(t *testing.T) { + r := NewRouter[string, string](RouterConfig{}, nil, nil) + r.RegisterRoute(RouteConfigBase{ + Path: "/healthz", + Methods: []HttpMethod{MethodGet}, + Overrides: common.RouteOverrides{ + Timeout: 2 * time.Second, + }, + Handler: func(http.ResponseWriter, *http.Request) {}, + }) + + path := filepath.Join(t.TempDir(), "spec.json") + if err := r.ExportJSONFile(path); err != nil { + t.Fatalf("ExportJSONFile returned error: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed reading export file: %v", err) + } + + var spec ExportSpec + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid export JSON: %v", err) + } + if len(spec.Routes) != 1 || spec.Routes[0].Path != "/healthz" { + t.Fatalf("unexpected route export: %+v", spec.Routes) + } +} diff --git a/pkg/router/reflect.go b/pkg/router/reflect.go new file mode 100644 index 0000000..054f48e --- /dev/null +++ b/pkg/router/reflect.go @@ -0,0 +1,94 @@ +package router + +import ( + "reflect" + "strings" +) + +const maxSchemaDepth = 10 + +func buildTypeSchema(t reflect.Type) *TypeSchema { + return buildTypeSchemaDepth(t, 0) +} + +func buildTypeSchemaDepth(t reflect.Type, depth int) *TypeSchema { + if t == nil || depth > maxSchemaDepth { + return nil + } + + for t.Kind() == reflect.Pointer { + t = t.Elem() + if t == nil { + return nil + } + } + + schema := &TypeSchema{ + TypeName: t.Name(), + Package: t.PkgPath(), + Kind: strings.ToLower(t.Kind().String()), + } + + if t.Kind() != reflect.Struct { + return schema + } + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + + fieldSchema := FieldSchema{ + Name: field.Name, + Type: field.Type.String(), + } + + tag := field.Tag.Get("json") + if tag != "" { + parts := strings.Split(tag, ",") + if parts[0] != "" && parts[0] != "-" { + fieldSchema.JSONName = parts[0] + } + fieldSchema.Required = !contains(parts[1:], "omitempty") + } + + nestedType := field.Type + for nestedType.Kind() == reflect.Pointer { + nestedType = nestedType.Elem() + } + switch nestedType.Kind() { + case reflect.Struct: + fieldSchema.Schema = buildTypeSchemaDepth(nestedType, depth+1) + case reflect.Slice, reflect.Array: + elem := nestedType.Elem() + for elem.Kind() == reflect.Pointer { + elem = elem.Elem() + } + if elem.Kind() == reflect.Struct { + fieldSchema.Schema = buildTypeSchemaDepth(elem, depth+1) + } + case reflect.Map: + elem := nestedType.Elem() + for elem.Kind() == reflect.Pointer { + elem = elem.Elem() + } + if elem.Kind() == reflect.Struct { + fieldSchema.Schema = buildTypeSchemaDepth(elem, depth+1) + } + } + + schema.Fields = append(schema.Fields, fieldSchema) + } + + return schema +} + +func contains(values []string, target string) bool { + for _, v := range values { + if v == target { + return true + } + } + return false +} diff --git a/pkg/router/reflect_test.go b/pkg/router/reflect_test.go new file mode 100644 index 0000000..de3502a --- /dev/null +++ b/pkg/router/reflect_test.go @@ -0,0 +1,43 @@ +package router + +import ( + "reflect" + "testing" +) + +type reflectNested struct { + ID string `json:"id"` +} + +type reflectRequest struct { + Name string `json:"name"` + Age int `json:"age,omitempty"` + Nested *reflectNested `json:"nested"` + Items []reflectNested `json:"items,omitempty"` + skipMe string +} + +func TestBuildTypeSchema(t *testing.T) { + schema := buildTypeSchema(reflect.TypeOf(reflectRequest{})) + if schema == nil { + t.Fatal("expected schema") + } + if schema.Kind != "struct" { + t.Fatalf("expected struct kind, got %q", schema.Kind) + } + if len(schema.Fields) != 4 { + t.Fatalf("expected 4 exported fields, got %d", len(schema.Fields)) + } + if schema.Fields[0].JSONName != "name" || !schema.Fields[0].Required { + t.Fatalf("unexpected first field metadata: %+v", schema.Fields[0]) + } + if schema.Fields[1].Required { + t.Fatalf("omitempty field should not be required: %+v", schema.Fields[1]) + } + if schema.Fields[2].Schema == nil || schema.Fields[2].Schema.TypeName != "reflectNested" { + t.Fatalf("expected nested struct schema, got: %+v", schema.Fields[2].Schema) + } + if schema.Fields[3].Schema == nil || schema.Fields[3].Schema.Kind != "struct" { + t.Fatalf("expected slice element struct schema, got: %+v", schema.Fields[3].Schema) + } +} diff --git a/pkg/router/route.go b/pkg/router/route.go index e9b4891..69d8219 100644 --- a/pkg/router/route.go +++ b/pkg/router/route.go @@ -3,6 +3,7 @@ package router import ( "errors" "net/http" + "reflect" "time" "github.com/Suhaibinator/SRouter/pkg/codec" @@ -44,6 +45,18 @@ func (r *Router[T, U]) RegisterRoute(route RouteConfigBase) { for _, method := range route.Methods { r.router.Handle(string(method), route.Path, r.convertToHTTPRouterHandle(handler, route.Path)) // Convert HttpMethod to string } + + metadata := RouteMetadata{ + Path: route.Path, + Methods: routeMethodsToStrings(route.Methods), + AuthLevel: authLevelString(route.AuthLevel), + Timeout: durationString(timeout), + MaxBodySize: maxBodySize, + RateLimit: rateLimitMetadataFromRuntimeConfig(rateLimit), + AuthToken: authTokenMetadataFromConfig(authTokenConfig), + DisableTimeout: route.DisableTimeout, + } + r.appendRouteMetadata(metadata) } // RegisterGenericRoute registers a route with generic request and response types. @@ -68,6 +81,17 @@ func RegisterGenericRoute[Req any, Resp any, UserID comparable, User any]( effectiveTimeout time.Duration, effectiveMaxBodySize int64, effectiveRateLimit *common.RateLimitConfig[UserID, User], // Use common.RateLimitConfig +) { + registerGenericRouteInternal(r, route, effectiveTimeout, effectiveMaxBodySize, effectiveRateLimit, "") +} + +func registerGenericRouteInternal[Req any, Resp any, UserID comparable, User any]( + r *Router[UserID, User], + route RouteConfig[Req, Resp], + effectiveTimeout time.Duration, + effectiveMaxBodySize int64, + effectiveRateLimit *common.RateLimitConfig[UserID, User], + subRouterPrefix string, ) { // Warn if no sanitizer function is provided (only at registration time) if route.Sanitizer == nil { @@ -279,6 +303,28 @@ func RegisterGenericRoute[Req any, Resp any, UserID comparable, User any]( for _, method := range route.Methods { r.router.Handle(string(method), route.Path, r.convertToHTTPRouterHandle(wrappedHandler, route.Path)) // Convert HttpMethod to string } + + reqType := reflect.TypeOf((*Req)(nil)).Elem() + respType := reflect.TypeOf((*Resp)(nil)).Elem() + metadata := RouteMetadata{ + Path: route.Path, + Methods: routeMethodsToStrings(route.Methods), + AuthLevel: authLevelString(route.AuthLevel), + Timeout: durationString(effectiveTimeout), + MaxBodySize: effectiveMaxBodySize, + RateLimit: rateLimitMetadataFromRuntimeConfig(effectiveRateLimit), + AuthToken: authTokenMetadataFromConfig(authTokenConfig), + SubRouter: subRouterPrefix, + Request: &RequestSchema{ + Source: sourceTypeString(route.SourceType), + SourceKey: route.SourceKey, + Codec: route.Codec.Name(), + Schema: buildTypeSchema(reqType), + HasSanitizer: route.Sanitizer != nil, + }, + Response: buildTypeSchema(respType), + } + r.appendRouteMetadata(metadata) } // NewGenericRouteDefinition creates a GenericRouteRegistrationFunc for declarative configuration. @@ -327,6 +373,14 @@ func NewGenericRouteDefinition[Req any, Resp any, UserID comparable, User any]( finalRouteConfig.Overrides.AuthToken = &effectiveAuthTokenConfig // Call the underlying generic registration function with the modified config and effective settings - RegisterGenericRoute(r, finalRouteConfig, effectiveTimeout, effectiveMaxBodySize, effectiveRateLimit) + registerGenericRouteInternal(r, finalRouteConfig, effectiveTimeout, effectiveMaxBodySize, effectiveRateLimit, sr.PathPrefix) + } +} + +func routeMethodsToStrings(methods []HttpMethod) []string { + out := make([]string, len(methods)) + for i, method := range methods { + out[i] = string(method) } + return out } diff --git a/pkg/router/route_test.go b/pkg/router/route_test.go index c9665cf..67c39e8 100644 --- a/pkg/router/route_test.go +++ b/pkg/router/route_test.go @@ -17,6 +17,10 @@ import ( // Mock Codec for testing decoding errors type mockErrorCodec struct{} +func (c *mockErrorCodec) Name() string { + return "json" +} + func (m *mockErrorCodec) Encode(w http.ResponseWriter, v testResponse) error { // Correct signature // Not needed for these tests, but must match interface return nil diff --git a/pkg/router/router.go b/pkg/router/router.go index ceeed43..5ee9303 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -47,6 +47,9 @@ type Router[T comparable, U any] struct { corsAllowHeaders string corsExposeHeaders string corsMaxAge string + + metadataMu sync.RWMutex + routeMetadata []RouteMetadata } const defaultAuthHeaderName = "Authorization" @@ -243,6 +246,19 @@ func (r *Router[T, U]) registerSubRouter(sr SubRouterConfig) { r.router.Handle(string(method), fullPath, r.convertToHTTPRouterHandle(handler, fullPath)) // Convert HttpMethod to string } + metadata := RouteMetadata{ + Path: fullPath, + Methods: routeMethodsToStrings(route.Methods), + AuthLevel: authLevelString(authLevel), + Timeout: durationString(timeout), + MaxBodySize: maxBodySize, + RateLimit: rateLimitMetadataFromRuntimeConfig(rateLimit), + AuthToken: authTokenMetadataFromConfig(authTokenConfig), + SubRouter: sr.PathPrefix, + DisableTimeout: route.DisableTimeout, + } + r.appendRouteMetadata(metadata) + case GenericRouteRegistrationFunc[T, U]: // Handle generic route registration function // The function itself will handle calculating effective settings and calling RegisterGenericRoute @@ -558,7 +574,7 @@ func RegisterGenericRouteOnSubRouter[Req any, Resp any, UserID comparable, User finalRouteConfig.Overrides.AuthToken = &effectiveAuthTokenConfig // Call the underlying generic registration function with the modified config - RegisterGenericRoute(r, finalRouteConfig, effectiveTimeout, effectiveMaxBodySize, effectiveRateLimit) + registerGenericRouteInternal(r, finalRouteConfig, effectiveTimeout, effectiveMaxBodySize, effectiveRateLimit, pathPrefix) return nil }