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
}