diff --git a/gateway/route_doc.go b/gateway/route_doc.go new file mode 100644 index 000000000..558791cf3 --- /dev/null +++ b/gateway/route_doc.go @@ -0,0 +1,53 @@ +package gateway + +import ( + "context" + "fmt" + "github.com/viant/datly/repository/contract" + "net/http" +) + +// swaggerUITemplate renders a minimal Swagger UI page loaded from a public CDN. +// %s is replaced with the URL of the aggregate OpenAPI spec (JSON). +const swaggerUITemplate = ` + + + + Datly API + + + + + +
+ + + + +` + +// NewOpenAPIDocRoute serves an interactive Swagger UI page that renders the +// aggregate OpenAPI spec located at specURL. +func (r *Router) NewOpenAPIDocRoute(URL string, specURL string) *Route { + page := []byte(fmt.Sprintf(swaggerUITemplate, specURL)) + return &Route{ + Path: contract.NewPath(http.MethodGet, URL), + Handler: func(ctx context.Context, response http.ResponseWriter, req *http.Request) { + setContentType(response, http.StatusOK, "text/html") + write(response, http.StatusOK, page) + }, + Kind: RouteOpenAPIKind, + Config: r.config.Logging, + Version: r.config.Version, + } +} diff --git a/gateway/route_openapi.go b/gateway/route_openapi.go index d2f971c2c..51ae8e1db 100644 --- a/gateway/route_openapi.go +++ b/gateway/route_openapi.go @@ -2,11 +2,13 @@ package gateway import ( "context" + "encoding/json" "github.com/viant/datly/gateway/router/openapi" "github.com/viant/datly/repository" "github.com/viant/datly/repository/contract" "gopkg.in/yaml.v3" "net/http" + "strings" ) func (r *Router) NewOpenAPIRoute(URL string, components *repository.Service, providers ...*repository.Provider) *Route { @@ -44,3 +46,67 @@ func (r *Router) generateOpenAPI(ctx context.Context, components *repository.Ser return http.StatusOK, specMarshal } + +// NewOpenAPIAggregateRoute builds a route that serves a single OpenAPI 3.0.1 spec +// covering all supplied (public) providers. It defaults to JSON and can return YAML +// when the request asks for it via ?format=yaml or an Accept header containing yaml. +func (r *Router) NewOpenAPIAggregateRoute(URL string, components *repository.Service, providers ...*repository.Provider) *Route { + return &Route{ + Path: contract.NewPath(http.MethodGet, URL), + Providers: providers, + Handler: func(ctx context.Context, response http.ResponseWriter, req *http.Request) { + r.handleOpenAPIAggregate(ctx, components, response, req, providers) + }, + Kind: RouteOpenAPIKind, + Config: r.config.Logging, + Version: r.config.Version, + NewMultiRoute: func(routes []*contract.Path) *Route { + return r.NewOpenAPIAggregateRoute("", components, providers...) + }, + } +} + +func (r *Router) handleOpenAPIAggregate(ctx context.Context, components *repository.Service, res http.ResponseWriter, request *http.Request, providers []*repository.Provider) { + asYAML := wantsYAML(request) + statusCode, content, contentType := r.generateOpenAPIWithFormat(ctx, components, providers, asYAML) + setContentType(res, statusCode, contentType) + write(res, statusCode, content) +} + +func (r *Router) generateOpenAPIWithFormat(ctx context.Context, components *repository.Service, providers []*repository.Provider, asYAML bool) (int, []byte, string) { + spec, err := openapi.GenerateOpenAPI3Spec(ctx, components, r.OpenAPIInfo, providers...) + if err != nil { + return http.StatusInternalServerError, []byte(err.Error()), "text/plain" + } + + if asYAML { + specMarshal, err := yaml.Marshal(spec) + if err != nil { + return http.StatusInternalServerError, []byte(err.Error()), "text/plain" + } + return http.StatusOK, specMarshal, "text/yaml" + } + + specMarshal, err := json.MarshalIndent(spec, "", " ") + if err != nil { + return http.StatusInternalServerError, []byte(err.Error()), "text/plain" + } + return http.StatusOK, specMarshal, "application/json" +} + +func wantsYAML(request *http.Request) bool { + if request == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(request.URL.Query().Get("format"))) { + case "yaml", "yml": + return true + case "json": + return false + } + accept := strings.ToLower(request.Header.Get("Accept")) + if strings.Contains(accept, "yaml") { + return true + } + return false +} diff --git a/gateway/route_openapi_test.go b/gateway/route_openapi_test.go new file mode 100644 index 000000000..f6a14cb5c --- /dev/null +++ b/gateway/route_openapi_test.go @@ -0,0 +1,71 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRouterNewOpenAPIAggregateRoute(t *testing.T) { + router := &Router{config: &Config{}} + + route := router.NewOpenAPIAggregateRoute("/v1/api/meta/openapi", nil) + + require.NotNil(t, route) + require.Equal(t, "/v1/api/meta/openapi", route.Path.URI) + require.Equal(t, http.MethodGet, route.Path.Method) + require.Equal(t, RouteOpenAPIKind, route.Kind) + require.NotNil(t, route.Handler) +} + +func TestRouterNewOpenAPIDocRoute(t *testing.T) { + router := &Router{config: &Config{}} + + route := router.NewOpenAPIDocRoute("/v1/api/meta/doc", "/v1/api/meta/openapi") + + require.NotNil(t, route) + require.Equal(t, "/v1/api/meta/doc", route.Path.URI) + require.Equal(t, http.MethodGet, route.Path.Method) + require.NotNil(t, route.Handler) + + recorder := httptest.NewRecorder() + route.Handler(nil, recorder, nil) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Header().Get("Content-Type"), "text/html") + body := recorder.Body.String() + require.Contains(t, body, "swagger-ui") + require.Contains(t, body, `"/v1/api/meta/openapi"`) +} + +func TestWantsYAML(t *testing.T) { + testCases := []struct { + description string + rawQuery string + accept string + expect bool + }{ + {description: "default is json", expect: false}, + {description: "format=yaml", rawQuery: "format=yaml", expect: true}, + {description: "format=yml", rawQuery: "format=yml", expect: true}, + {description: "format=json overrides accept", rawQuery: "format=json", accept: "application/yaml", expect: false}, + {description: "accept yaml", accept: "application/yaml", expect: true}, + {description: "accept json", accept: "application/json", expect: false}, + } + + for _, testCase := range testCases { + request := &http.Request{ + URL: &url.URL{RawQuery: testCase.rawQuery}, + Header: http.Header{}, + } + if testCase.accept != "" { + request.Header.Set("Accept", testCase.accept) + } + require.Equalf(t, testCase.expect, wantsYAML(request), testCase.description) + } + + require.False(t, wantsYAML(nil)) +} diff --git a/gateway/router.go b/gateway/router.go index 6af07904f..c520fe902 100644 --- a/gateway/router.go +++ b/gateway/router.go @@ -83,6 +83,10 @@ func NewRouter(ctx context.Context, components *repository.Service, config *Conf apiKeyMatcher: newApiKeyMatcher(config.APIKeys), mcpRegistry: mcpRegistry, logger: logging.New(logging.INFO, nil), + OpenAPIInfo: openapi3.Info{ + Title: "Datly API", + Version: config.Version, + }, } return r, r.init(ctx) } @@ -333,6 +337,7 @@ func (r *Router) newMatcher(ctx context.Context) (*matcher.Matcher, []*contract. unique := map[string]bool{} var openAPIs = map[string][]*repository.Provider{} + var allProviders []*repository.Provider var optionsPaths = map[string][]*path.Path{} for _, anItem := range container.Items { for _, aPath := range anItem.Paths { @@ -400,6 +405,7 @@ func (r *Router) newMatcher(ctx context.Context) (*matcher.Matcher, []*contract. routes = append(routes, r.NewViewMetaHandler(r.routeURL(r.config.Meta.ViewURI, aPath.URI), provider)) key := r.routeURL(r.config.Meta.OpenApiURI, aPath.URI) openAPIs[key] = append(openAPIs[key], provider) + allProviders = append(allProviders, provider) if !unique[aPath.URI] { unique[aPath.URI] = true @@ -426,6 +432,13 @@ func (r *Router) newMatcher(ctx context.Context) (*matcher.Matcher, []*contract. routes = append(routes, r.NewOpenAPIRoute(key, r.repository, providers...)) } + if strings.TrimSpace(r.config.Meta.OpenApiURI) != "" { + routes = append(routes, r.NewOpenAPIAggregateRoute(r.config.Meta.OpenApiURI, r.repository, allProviders...)) + if strings.TrimSpace(r.config.Meta.DocURI) != "" { + routes = append(routes, r.NewOpenAPIDocRoute(r.config.Meta.DocURI, r.config.Meta.OpenApiURI)) + } + } + for uri, paths := range optionsPaths { routes = append(routes, r.NewOptionsRoute(uri, paths)) diff --git a/gateway/router/openapi/generator_operation.go b/gateway/router/openapi/generator_operation.go index 704ffcfb2..a89ffff6a 100644 --- a/gateway/router/openapi/generator_operation.go +++ b/gateway/router/openapi/generator_operation.go @@ -10,6 +10,8 @@ import ( ) func (g *generator) generateOperation(ctx context.Context, component *ComponentSchema) (*openapi.Operation, error) { + g.authSchemeName = "" + body, err := g.requestBody(ctx, component) if err != nil { return nil, err @@ -25,11 +27,16 @@ func (g *generator) generateOperation(ctx context.Context, component *ComponentS return nil, err } - return &openapi.Operation{ + operation := &openapi.Operation{ Parameters: dedupe(parameters), RequestBody: body, Responses: responses, - }, nil + } + if g.authSchemeName != "" { + security := openapi.SecurityRequirements{openapi.SecurityRequirement{g.authSchemeName: []string{}}} + operation.Security = &security + } + return operation, nil } func (g *generator) operationParameters(ctx context.Context, component *ComponentSchema) ([]*openapi.Parameter, error) { diff --git a/gateway/router/openapi/generator_test.go b/gateway/router/openapi/generator_test.go index 6a3756740..7a68b68b2 100644 --- a/gateway/router/openapi/generator_test.go +++ b/gateway/router/openapi/generator_test.go @@ -262,6 +262,58 @@ func TestGeneratorHelpersMore_Table(t *testing.T) { } }) + t.Run("path parameter is emitted and required", func(t *testing.T) { + g := &generator{ + _parametersIndex: map[string]*openapi3.Parameter{}, + commonParameters: map[string]*openapi3.Parameter{}, + } + comp := newTestComponent(t) + comp.View = &view.View{Template: &view.Template{}, Selector: &view.Config{}} + cSchema := &ComponentSchema{component: comp, schemas: NewContainer()} + param := &state.Parameter{Name: "ID", In: &state.Location{Kind: state.KindPath, Name: "id"}, Schema: state.NewSchema(reflect.TypeOf(1))} + + converted, ok, err := g.convertParam(context.Background(), cSchema, param, "") + if err != nil || !ok || len(converted) != 1 { + t.Fatalf("unexpected convert result: ok=%v err=%v n=%d", ok, err, len(converted)) + } + if converted[0].In != "path" { + t.Fatalf("expected in=path, got %q", converted[0].In) + } + if converted[0].Name != "id" { + t.Fatalf("expected name=id, got %q", converted[0].Name) + } + if !converted[0].Required { + t.Fatalf("expected path parameter to be required") + } + }) + + t.Run("authorization header becomes security scheme", func(t *testing.T) { + g := &generator{ + _parametersIndex: map[string]*openapi3.Parameter{}, + commonParameters: map[string]*openapi3.Parameter{}, + securitySchemes: map[string]*openapi3.SecurityScheme{}, + } + comp := newTestComponent(t) + comp.View = &view.View{Template: &view.Template{}, Selector: &view.Config{}} + cSchema := &ComponentSchema{component: comp, schemas: NewContainer()} + param := &state.Parameter{Name: "Jwt", In: &state.Location{Kind: state.KindHeader, Name: "Authorization"}, Schema: state.NewSchema(reflect.TypeOf(""))} + + converted, ok, err := g.convertParam(context.Background(), cSchema, param, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || len(converted) != 0 { + t.Fatalf("expected Authorization header to be skipped as a parameter, got ok=%v n=%d", ok, len(converted)) + } + if g.authSchemeName != bearerAuthSchemeName { + t.Fatalf("expected auth scheme %q, got %q", bearerAuthSchemeName, g.authSchemeName) + } + scheme := g.securitySchemes[bearerAuthSchemeName] + if scheme == nil || scheme.Type != "http" || scheme.Scheme != "bearer" || scheme.BearerFormat != "JWT" { + t.Fatalf("unexpected security scheme: %+v", scheme) + } + }) + t.Run("convert param kind whitelist", func(t *testing.T) { testCases := []struct { name string @@ -271,8 +323,8 @@ func TestGeneratorHelpersMore_Table(t *testing.T) { {name: "header", kind: state.KindHeader, expectKeep: true}, {name: "query", kind: state.KindQuery, expectKeep: true}, {name: "form", kind: state.KindForm, expectKeep: true}, + {name: "path", kind: state.KindPath, expectKeep: true}, {name: "body skipped in parameter list", kind: state.KindRequestBody, expectKeep: false}, - {name: "path skipped", kind: state.KindPath, expectKeep: false}, {name: "cookie skipped", kind: state.KindCookie, expectKeep: false}, {name: "state skipped", kind: state.KindState, expectKeep: false}, } diff --git a/gateway/router/openapi/openapi3.go b/gateway/router/openapi/openapi3.go index a92a38894..6cd7de67e 100644 --- a/gateway/router/openapi/openapi3.go +++ b/gateway/router/openapi/openapi3.go @@ -12,6 +12,7 @@ import ( "github.com/viant/xdatly/handler/response" "net/http" "reflect" + "strings" ) const ( @@ -41,6 +42,11 @@ type ( _schemasIndex map[string]*openapi.Schema commonParameters openapi.ParametersMap _parametersIndex map[string]*openapi.Parameter + securitySchemes map[string]*openapi.SecurityScheme + // authSchemeName is set transiently while building a single operation + // when it declares an Authorization header, so the operation can require + // the corresponding security scheme. + authSchemeName string } paramLocation struct { @@ -51,7 +57,7 @@ type ( func isRequestDerivedInputKind(kind state.Kind) bool { switch kind { - case state.KindHeader, state.KindRequestBody, state.KindQuery, state.KindForm: + case state.KindHeader, state.KindRequestBody, state.KindQuery, state.KindForm, state.KindPath: return true default: return false @@ -60,7 +66,7 @@ func isRequestDerivedInputKind(kind state.Kind) bool { func isOpenAPIParameterKind(kind state.Kind) bool { switch kind { - case state.KindHeader, state.KindQuery, state.KindForm: + case state.KindHeader, state.KindQuery, state.KindForm, state.KindPath: return true default: return false @@ -77,6 +83,9 @@ func (g *generator) GenerateSpec(ctx context.Context, repoComponents *repository components.Schemas = schemas.generatedSchemas components.Parameters = g.commonParameters + if len(g.securitySchemes) > 0 { + components.SecuritySchemes = g.securitySchemes + } return &openapi.OpenAPI{ OpenAPI: "3.0.1", @@ -91,9 +100,32 @@ func GenerateOpenAPI3Spec(ctx context.Context, components *repository.Service, i _schemasIndex: map[string]*openapi.Schema{}, commonParameters: map[string]*openapi.Parameter{}, _parametersIndex: map[string]*openapi.Parameter{}, + securitySchemes: map[string]*openapi.SecurityScheme{}, }).GenerateSpec(ctx, components, info, providers...) } +// bearerAuthSchemeName is the name of the JWT bearer security scheme emitted +// for routes that declare an Authorization header. +const bearerAuthSchemeName = "BearerAuth" + +// ensureBearerScheme registers (once) an HTTP bearer JWT security scheme and +// returns its name. datly's JWT codec accepts both a raw token and a +// "Bearer " value, so the bearer scheme is safe. +func (g *generator) ensureBearerScheme() string { + if g.securitySchemes == nil { + g.securitySchemes = map[string]*openapi.SecurityScheme{} + } + if _, ok := g.securitySchemes[bearerAuthSchemeName]; !ok { + g.securitySchemes[bearerAuthSchemeName] = &openapi.SecurityScheme{ + Type: "http", + Scheme: "bearer", + BearerFormat: "JWT", + Description: "JWT bearer token sent in the Authorization header. Paste the token only; the 'Bearer ' prefix is added automatically.", + } + } + return bearerAuthSchemeName +} + func dedupe(parameters []*openapi.Parameter) openapi.Parameters { index := map[paramLocation]bool{} var result []*openapi.Parameter @@ -228,6 +260,13 @@ func (g *generator) convertParam(ctx context.Context, component *ComponentSchema return result, true, nil } + // Represent the Authorization header as a security scheme (Swagger UI + // "Authorize" button) instead of a plain header parameter. + if param.In.Kind == state.KindHeader && strings.EqualFold(param.In.Name, "Authorization") { + g.authSchemeName = g.ensureBearerScheme() + return nil, false, nil + } + if !isOpenAPIParameterKind(param.In.Kind) { return nil, false, nil } @@ -279,8 +318,9 @@ func (g *generator) convertParam(ctx context.Context, component *ComponentSchema In: string(param.In.Kind), Description: description, Style: param.Style, - Required: param.IsRequired(), - Schema: schema, + // OpenAPI requires path parameters to always be required. + Required: param.IsRequired() || param.In.Kind == state.KindPath, + Schema: schema, } g._parametersIndex[param.Name] = convertedParam diff --git a/gateway/router/openapi/schema.go b/gateway/router/openapi/schema.go index 3eef32319..f4a023520 100644 --- a/gateway/router/openapi/schema.go +++ b/gateway/router/openapi/schema.go @@ -13,6 +13,7 @@ import ( "github.com/viant/xdatly/docs" "github.com/viant/xreflect" "reflect" + "strings" "sync" ) @@ -49,6 +50,12 @@ type ( index map[string]int generatedSchemas map[string]*openapi3.Schema visitingTypes map[string]int + // typeNameByKey maps a unique type identity (import path + name) to the + // schema name assigned to it, and keyByName is the reverse mapping used + // to detect and disambiguate collisions between same-named types from + // different packages within a single (aggregate) spec. + typeNameByKey map[string]string + keyByName map[string]string } ) @@ -92,6 +99,8 @@ func NewContainer() *SchemaContainer { index: map[string]int{}, generatedSchemas: map[string]*openapi3.Schema{}, visitingTypes: map[string]int{}, + typeNameByKey: map[string]string{}, + keyByName: map[string]string{}, } } @@ -110,14 +119,27 @@ func NewComponentSchema(components *repository.Service, component *repository.Co } func (c *ComponentSchema) RequestBody(ctx context.Context) (*Schema, error) { - inputType := c.component.Input.Type + // Generate the request body schema from the actual body type (what the + // server unmarshals the payload into) rather than the whole input state, + // which may resolve to an opaque scalar for named body parameters. + bodyType := c.component.Input.Body + if bodyType.Schema == nil || bodyType.Schema.Type() == nil { + bodyType = c.component.Input.Type + } - name := inputType.SimpleTypeName() + // A unique, deterministic name is required because the aggregate spec shares + // one schema container across all routes; a constant fallback would make + // every request body collide on a single "RequestBody" schema. + name := bodyType.SimpleTypeName() if name == "" { - name = "Input" + if base := bodyTypeName(bodyType.Schema.Type()); base != "" { + name = base + "RequestBody" + } else { + name = c.pathDerivedName("RequestBody") + } } - result, err := c.TypedSchema(ctx, inputType, name, c.component.IOConfig(), true) + result, err := c.TypedSchema(ctx, bodyType, name, c.component.IOConfig(), true) if err != nil { return nil, err } @@ -126,11 +148,58 @@ func (c *ComponentSchema) RequestBody(ctx context.Context) (*Schema, error) { return result, nil } +// bodyTypeName derives a representative type name from a (possibly wrapped) +// request body type, e.g. struct{ Data []*patch.Campaign } -> "Campaign". +func bodyTypeName(rType reflect.Type) string { + if rType == nil { + return "" + } + for rType.Kind() == reflect.Ptr { + rType = rType.Elem() + } + switch rType.Kind() { + case reflect.Slice, reflect.Array: + return bodyTypeName(rType.Elem()) + case reflect.Struct: + if rType.Name() != "" { + return rType.Name() + } + for i := 0; i < rType.NumField(); i++ { + if rType.Field(i).PkgPath != "" { + continue + } + if name := bodyTypeName(rType.Field(i).Type); name != "" { + return name + } + } + } + return "" +} + +// pathDerivedName builds a unique schema name from the route URI, used as a last +// resort when a body type has no resolvable name. +func (c *ComponentSchema) pathDerivedName(suffix string) string { + uri := strings.Trim(c.component.Path.URI, "/") + base := state.SanitizeTypeName(strings.ReplaceAll(uri, "/", "_")) + if base == "" { + return suffix + } + return base + suffix +} + func (c *ComponentSchema) ResponseBody(ctx context.Context) (*Schema, error) { name := c.component.Output.Type.SimpleTypeName() if name == "" { - name = "Output" + var base string + if c.component.Output.Type.Schema != nil { + base = bodyTypeName(c.component.Output.Type.Schema.Type()) + } + if base != "" { + name = base + "Output" + } else { + name = c.pathDerivedName("Output") + } } schema, err := c.TypedSchema(ctx, c.component.Output.Type, name, c.component.IOConfig(), false) if err != nil { @@ -265,7 +334,9 @@ func (c *ComponentSchema) GenerateSchema(ctx context.Context, schema *Schema) (* ReadOnly: schema.tag.ReadOnly, MaxItems: schema.tag.MaxItems, Default: schema.tag.Default, - Example: schema.tag.Example, + } + if schema.tag.Example != "" { + result.Example = schema.tag.Example } if err := c.schemas.addToSchema(ctx, c, result, schema); err != nil { diff --git a/gateway/router/openapi/schema_build.go b/gateway/router/openapi/schema_build.go index e51b27450..4e54d2514 100644 --- a/gateway/router/openapi/schema_build.go +++ b/gateway/router/openapi/schema_build.go @@ -12,6 +12,7 @@ import ( "os" "reflect" "sort" + "strconv" "strings" "time" @@ -142,6 +143,9 @@ func (c *SchemaContainer) addDefaultSchema(ctx context.Context, component *Compo } dst.Type = apiType dst.Format = format + if dst.Example == nil { + dst.Example = exampleValueForType(rType, "") + } return nil } } @@ -314,9 +318,13 @@ func (c *SchemaContainer) createSchema(ctx context.Context, componentSchema *Com return nil, err } - if fieldSchema.tag.TypeName != "" { - if _, ok := c.generatedSchemas[fieldSchema.tag.TypeName]; ok { - return c.SchemaRef(fieldSchema.tag.TypeName, description), nil + // Resolve a schema name that is unique per type identity so same-named types + // from different packages do not collide in a shared (aggregate) spec. + schemaName := c.resolveSchemaName(fieldSchema.tag.TypeName, fieldSchema.rType) + + if schemaName != "" { + if _, ok := c.generatedSchemas[schemaName]; ok { + return c.SchemaRef(schemaName, description), nil } } @@ -325,32 +333,88 @@ func (c *SchemaContainer) createSchema(ctx context.Context, componentSchema *Com Type: apiType, Format: format, Description: description, - Example: example, + Example: exampleValueForType(fieldSchema.rType, example), }, nil } // Mark named schemas as in-progress before generation so recursive graphs // (for example polymorphic self references) resolve to $ref instead of looping. - if fieldSchema.tag.TypeName != "" { - c.generatedSchemas[fieldSchema.tag.TypeName] = nil + if schemaName != "" { + c.generatedSchemas[schemaName] = nil } schema, err := componentSchema.GenerateSchema(ctx, fieldSchema) if err != nil { - if fieldSchema.tag.TypeName != "" { - delete(c.generatedSchemas, fieldSchema.tag.TypeName) + if schemaName != "" { + delete(c.generatedSchemas, schemaName) } return nil, err } - if fieldSchema.tag.TypeName != "" { - c.generatedSchemas[fieldSchema.tag.TypeName] = schema + if schemaName != "" { + c.generatedSchemas[schemaName] = schema c.schemas = append(c.schemas, schema) - schema = c.SchemaRef(fieldSchema.tag.TypeName, description) + schema = c.SchemaRef(schemaName, description) } return schema, nil } +// schemaTypeKey returns a globally-unique identity for a type. It recurses +// through pointers, slices, arrays and maps and uses the full import path for +// named types so that composite types whose reflect.String() collapses to the +// same short package name (e.g. []*campaign/patch.Campaign vs +// []*adorder/patch.Campaign, both "[]*patch.Campaign") do not collide. +func schemaTypeKey(rType reflect.Type) string { + if rType == nil { + return "" + } + for rType.Kind() == reflect.Ptr { + rType = rType.Elem() + } + switch rType.Kind() { + case reflect.Slice: + return "[]" + schemaTypeKey(rType.Elem()) + case reflect.Array: + return "[" + strconv.Itoa(rType.Len()) + "]" + schemaTypeKey(rType.Elem()) + case reflect.Map: + return "map[" + schemaTypeKey(rType.Key()) + "]" + schemaTypeKey(rType.Elem()) + } + if rType.Name() != "" { + if pkg := rType.PkgPath(); pkg != "" { + return pkg + "." + rType.Name() + } + return rType.Name() + } + return rType.String() +} + +// resolveSchemaName maps a requested type name to a container-unique schema name +// keyed by the type's true identity. The first type to claim a name keeps it; +// subsequent distinct types with the same requested name get a numeric suffix. +func (c *SchemaContainer) resolveSchemaName(typeName string, rType reflect.Type) string { + if typeName == "" { + return "" + } + key := schemaTypeKey(rType) + if key == "" { + return typeName + } + if name, ok := c.typeNameByKey[key]; ok { + return name + } + name := typeName + for i := 2; ; i++ { + boundKey, taken := c.keyByName[name] + if !taken || boundKey == key { + break + } + name = typeName + strconv.Itoa(i) + } + c.typeNameByKey[key] = name + c.keyByName[name] = key + return name +} + func (c *SchemaContainer) SchemaRef(schemaName string, description string) *openapi3.Schema { return &openapi3.Schema{ Ref: "#/components/schemas/" + schemaName, @@ -472,6 +536,28 @@ func applySchemaExample(dst *openapi3.Schema, schema *Schema) { } } +// exampleValueForType returns a sample value for a primitive rType. When an +// explicit example is provided it is used as-is; otherwise a type-appropriate +// default is returned so tools such as Swagger UI render meaningful sample +// values (e.g. "string", 0, false) instead of empty double quotes. +func exampleValueForType(rType reflect.Type, provided string) interface{} { + if provided != "" { + return provided + } + switch dereferenceType(rType).Kind() { + case reflect.String: + return "string" + case reflect.Bool: + return false + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return 0 + case reflect.Float32, reflect.Float64: + return 0.0 + } + return nil +} + func addTimeSchema(dst *openapi3.Schema, schema *Schema) { dst.Type = stringOutput timeLayout := schema.tag._tag.TimeLayout diff --git a/gateway/router/openapi/schema_build_helpers_test.go b/gateway/router/openapi/schema_build_helpers_test.go index 0fc1516ef..fe50d4f7c 100644 --- a/gateway/router/openapi/schema_build_helpers_test.go +++ b/gateway/router/openapi/schema_build_helpers_test.go @@ -56,6 +56,30 @@ func TestSchemaBuildHelpers_Table(t *testing.T) { } }) + t.Run("example value for type", func(t *testing.T) { + cases := []struct { + name string + rType reflect.Type + provided string + expect interface{} + }{ + {name: "string default", rType: reflect.TypeOf(""), expect: "string"}, + {name: "bool default", rType: reflect.TypeOf(false), expect: false}, + {name: "int default", rType: reflect.TypeOf(0), expect: 0}, + {name: "int64 default", rType: reflect.TypeOf(int64(0)), expect: 0}, + {name: "float default", rType: reflect.TypeOf(0.0), expect: 0.0}, + {name: "pointer int default", rType: reflect.TypeOf(new(int)), expect: 0}, + {name: "provided overrides", rType: reflect.TypeOf(""), provided: "abc", expect: "abc"}, + {name: "provided on int", rType: reflect.TypeOf(0), provided: "42", expect: "42"}, + {name: "unsupported returns nil", rType: reflect.TypeOf(struct{}{}), expect: nil}, + } + for _, tc := range cases { + if got := exampleValueForType(tc.rType, tc.provided); got != tc.expect { + t.Fatalf("%s: expected %v (%T), got %v (%T)", tc.name, tc.expect, tc.expect, got, got) + } + } + }) + t.Run("root table", func(t *testing.T) { queryComp := &ComponentSchema{component: &repository.Component{View: &view.View{Mode: view.ModeQuery, Table: "users"}}} if got := rootTable(queryComp); got != "users" { diff --git a/gateway/router/openapi/schema_helpers_test.go b/gateway/router/openapi/schema_helpers_test.go index ddde60523..384342e6a 100644 --- a/gateway/router/openapi/schema_helpers_test.go +++ b/gateway/router/openapi/schema_helpers_test.go @@ -81,6 +81,125 @@ func TestSchemaField(t *testing.T) { } } +type sampleBody struct { + Name string + Age int + Score float64 + Active bool + Comment string `json:"comment" example:"looks good"` +} + +func TestGeneratedPrimitiveExamples(t *testing.T) { + container := NewContainer() + component := &ComponentSchema{component: &repository.Component{View: &view.View{}}, schemas: container} + + generated, err := container.createSchema(context.Background(), component, &Schema{ + rType: reflect.TypeOf(sampleBody{}), + tag: Tag{TypeName: "SampleBody"}, + isInput: true, + ioConfig: component.component.IOConfig(), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Named types resolve to a $ref; find the concrete schema in the container. + var body *openapi3.Schema + for _, s := range container.schemas { + if s != nil && len(s.Properties) > 0 { + body = s + break + } + } + if body == nil { + if generated != nil && len(generated.Properties) > 0 { + body = generated + } else { + t.Fatalf("expected a generated object schema with properties") + } + } + + expect := map[string]interface{}{ + "Name": "string", + "Age": 0, + "Score": 0.0, + "Active": false, + "comment": "looks good", + } + for name, want := range expect { + prop, ok := body.Properties[name] + if !ok { + t.Fatalf("missing property %q; have %v", name, keysOf(body.Properties)) + } + if prop.Example != want { + t.Fatalf("property %q: expected example %v (%T), got %v (%T)", name, want, want, prop.Example, prop.Example) + } + } +} + +func keysOf(m openapi3.Schemas) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +type namedCampaign struct { + ID int + Name string +} + +func TestBodyTypeName(t *testing.T) { + cases := []struct { + name string + rType reflect.Type + expect string + }{ + {name: "named struct", rType: reflect.TypeOf(namedCampaign{}), expect: "namedCampaign"}, + {name: "pointer struct", rType: reflect.TypeOf(&namedCampaign{}), expect: "namedCampaign"}, + {name: "slice of pointer", rType: reflect.TypeOf([]*namedCampaign{}), expect: "namedCampaign"}, + {name: "wrapped named body", rType: reflect.TypeOf(struct{ Data []*namedCampaign }{}), expect: "namedCampaign"}, + {name: "anonymous scalar wrapper", rType: reflect.TypeOf(struct{ Data string }{}), expect: ""}, + {name: "nil", rType: nil, expect: ""}, + } + for _, tc := range cases { + if got := bodyTypeName(tc.rType); got != tc.expect { + t.Fatalf("%s: expected %q, got %q", tc.name, tc.expect, got) + } + } +} + +func TestResolveSchemaName(t *testing.T) { + container := NewContainer() + + type outputA struct{ A int } + type outputB struct{ B int } + + // Same requested name "Output" for two distinct types must disambiguate. + n1 := container.resolveSchemaName("Output", reflect.TypeOf(outputA{})) + n2 := container.resolveSchemaName("Output", reflect.TypeOf(outputB{})) + if n1 != "Output" { + t.Fatalf("expected first type to keep name Output, got %q", n1) + } + if n2 == n1 { + t.Fatalf("expected distinct name for second type, got %q for both", n2) + } + + // Idempotent: same type resolves to the same name. + if again := container.resolveSchemaName("Output", reflect.TypeOf(outputA{})); again != n1 { + t.Fatalf("expected stable name %q, got %q", n1, again) + } + if againB := container.resolveSchemaName("Output", reflect.TypeOf(&outputB{})); againB != n2 { + t.Fatalf("expected stable name %q for pointer of same type, got %q", n2, againB) + } + + // Empty type name yields empty (inline schema). + if got := container.resolveSchemaName("", reflect.TypeOf(outputA{})); got != "" { + t.Fatalf("expected empty name, got %q", got) + } +} + func TestContainsAny(t *testing.T) { tests := []struct { name string diff --git a/gateway/runtime/meta/config.go b/gateway/runtime/meta/config.go index aa93d534b..5f76516c3 100644 --- a/gateway/runtime/meta/config.go +++ b/gateway/runtime/meta/config.go @@ -11,6 +11,8 @@ const ( ViewURI = "/v1/api/meta/view" //OpenApiURI represents default config openapi URIPrefix OpenApiURI = "/v1/api/meta/openapi" + //DocURI represents default Swagger UI documentation URI + DocURI = "/v1/api/meta/doc" //CacheWarmupURI URIPrefix default value CacheWarmupURI = "/v1/api/cache/warmup" //StructURI URIPrefix that generates a Golang struct representation @@ -29,6 +31,7 @@ type Config struct { StatusURI string ViewURI string OpenApiURI string + DocURI string CacheWarmURI string StructURI string StateURI string @@ -55,6 +58,9 @@ func (m *Config) Init() { if m.OpenApiURI == "" { m.OpenApiURI = OpenApiURI } + if m.DocURI == "" { + m.DocURI = DocURI + } if m.CacheWarmURI == "" { m.CacheWarmURI = CacheWarmupURI }