// @title datarhei Core API // @version 3.0 // @description Expose REST API for the datarhei Core // @contact.name datarhei Core Support // @contact.url https://www.datarhei.com // @contact.email hello@datarhei.com // @license.name Apache 2.0 // @license.url https://github.com/datarhei/core/v16/blob/main/LICENSE // @BasePath / // @securityDefinitions.apikey ApiKeyAuth // @in header // @name Authorization // @Param Authorization header string true "Insert your access token" default(Bearer ) // @securityDefinitions.apikey ApiRefreshKeyAuth // @in header // @name Authorization // @securityDefinitions.apikey Auth0KeyAuth // @in header // @name Authorization // @securityDefinitions.basic BasicAuth package http import ( "fmt" "maps" "net/http" "strings" "sync" "time" "github.com/datarhei/core/v16/cluster" cfgstore "github.com/datarhei/core/v16/config/store" "github.com/datarhei/core/v16/http/cache" "github.com/datarhei/core/v16/http/errorhandler" "github.com/datarhei/core/v16/http/fs" "github.com/datarhei/core/v16/http/graph/resolver" "github.com/datarhei/core/v16/http/handler" api "github.com/datarhei/core/v16/http/handler/api" httplog "github.com/datarhei/core/v16/http/log" "github.com/datarhei/core/v16/http/router" serverhandler "github.com/datarhei/core/v16/http/server" "github.com/datarhei/core/v16/http/validator" "github.com/datarhei/core/v16/iam" "github.com/datarhei/core/v16/log" "github.com/datarhei/core/v16/monitor" "github.com/datarhei/core/v16/net" "github.com/datarhei/core/v16/prometheus" "github.com/datarhei/core/v16/resources" "github.com/datarhei/core/v16/restream" "github.com/datarhei/core/v16/rtmp" "github.com/datarhei/core/v16/session" "github.com/datarhei/core/v16/srt" mwcache "github.com/datarhei/core/v16/http/middleware/cache" mwcompress "github.com/datarhei/core/v16/http/middleware/compress" mwcors "github.com/datarhei/core/v16/http/middleware/cors" mwhlsrewrite "github.com/datarhei/core/v16/http/middleware/hlsrewrite" mwiam "github.com/datarhei/core/v16/http/middleware/iam" mwiplimit "github.com/datarhei/core/v16/http/middleware/iplimit" mwlog "github.com/datarhei/core/v16/http/middleware/log" mwmime "github.com/datarhei/core/v16/http/middleware/mime" mwredirect "github.com/datarhei/core/v16/http/middleware/redirect" mwsession "github.com/datarhei/core/v16/http/middleware/session" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" echoSwagger "github.com/swaggo/echo-swagger" // echo-swagger middleware // Expose the API docs _ "github.com/datarhei/core/v16/docs" ) var ListenAndServe = http.ListenAndServe type Config struct { Logger log.Logger LogBuffer log.BufferWriter LogEvents log.ChannelWriter Restream restream.Restreamer Metrics monitor.HistoryReader Prometheus prometheus.Reader MimeTypesFile string MimeTypes map[string]string Filesystems []fs.FS IPLimiter net.IPLimitValidator Profiling bool Cors CorsConfig RTMP rtmp.Server SRT srt.Server Config cfgstore.Store Cache cache.Cacher Sessions session.RegistryReader Router router.Router ReadOnly bool Cluster cluster.Cluster IAM iam.IAM IAMSkipper func(ip string) bool Resources resources.Resources Compress CompressConfig } type CorsConfig struct { Origins []string } type CompressConfig struct { Encoding []string MimeTypes []string MinLength int } type server struct { logger log.Logger handler struct { about *api.AboutHandler prometheus *handler.PrometheusHandler profiling *handler.ProfilingHandler ping *handler.PingHandler graph *api.GraphHandler jwt *api.JWTHandler } v3handler struct { log *api.LogHandler events *api.EventsHandler process *api.ProcessHandler rtmp *api.RTMPHandler srt *api.SRTHandler config *api.ConfigHandler session *api.SessionHandler widget *api.WidgetHandler resources *api.MetricsHandler cluster *api.ClusterHandler iam *api.IAMHandler } middleware struct { iplimit echo.MiddlewareFunc log echo.MiddlewareFunc cors echo.MiddlewareFunc cache echo.MiddlewareFunc hlsrewrite echo.MiddlewareFunc iam echo.MiddlewareFunc } compress struct { encoding []string mimetypes []string minLength int } filesystems map[string]*filesystem router *echo.Echo mimeTypesFile string mimeTypes map[string]string profiling bool readOnly bool metrics struct { lock sync.Mutex status map[string]uint64 } } type filesystem struct { fs.FS handler *handler.FSHandler apihandler *handler.FSHandler middleware echo.MiddlewareFunc } func NewServer(config Config) (serverhandler.Server, error) { s := &server{ logger: config.Logger, mimeTypesFile: config.MimeTypesFile, mimeTypes: config.MimeTypes, profiling: config.Profiling, readOnly: config.ReadOnly, } s.metrics.status = map[string]uint64{} s.filesystems = map[string]*filesystem{} corsPrefixes := map[string][]string{ "/api": {"*"}, } for _, httpfs := range config.Filesystems { if _, ok := s.filesystems[httpfs.Name]; ok { return nil, fmt.Errorf("the filesystem name '%s' is already in use", httpfs.Name) } if !strings.HasPrefix(httpfs.Mountpoint, "/") { httpfs.Mountpoint = "/" + httpfs.Mountpoint } if !strings.HasSuffix(httpfs.Mountpoint, "/") { httpfs.Mountpoint = strings.TrimSuffix(httpfs.Mountpoint, "/") } if _, ok := corsPrefixes[httpfs.Mountpoint]; ok { return nil, fmt.Errorf("the mount point '%s' is already in use (%s)", httpfs.Mountpoint, httpfs.Name) } corsPrefixes[httpfs.Mountpoint] = config.Cors.Origins apihttpfs := httpfs if config.Cluster != nil { if httpfs.Filesystem.Type() == "disk" || httpfs.Filesystem.Type() == "mem" { httpfs.Filesystem = fs.NewClusterFS(httpfs.Filesystem.Name(), httpfs.Filesystem, config.Cluster.Manager()) } } filesystem := &filesystem{ FS: httpfs, handler: handler.NewFS(httpfs), apihandler: handler.NewFS(apihttpfs), } if httpfs.Filesystem.Type() == "disk" { filesystem.middleware = mwhlsrewrite.NewHLSRewriteWithConfig(mwhlsrewrite.HLSRewriteConfig{ PathPrefix: httpfs.Filesystem.Metadata("base"), }) } s.filesystems[filesystem.Name] = filesystem } if _, ok := corsPrefixes["/"]; !ok { return nil, fmt.Errorf("one filesystem must be mounted at /") } if config.Logger == nil { s.logger = log.New("") } mounts := []string{} for _, fs := range s.filesystems { mounts = append(mounts, fs.FS.Mountpoint) } s.middleware.iam = mwiam.NewWithConfig(mwiam.Config{ Skipper: func(c echo.Context) bool { return config.IAMSkipper(c.RealIP()) }, IAM: config.IAM, Mounts: mounts, WaitAfterFailedLogin: true, Logger: s.logger.WithComponent("IAM"), }) s.handler.about = api.NewAbout( config.Restream, config.Resources, func() []string { return config.IAM.Validators() }, ) s.handler.jwt = api.NewJWT(config.IAM) s.v3handler.iam = api.NewIAM(config.IAM) s.v3handler.log = api.NewLog( config.LogBuffer, ) s.v3handler.events = api.NewEvents( config.LogEvents, ) if config.Restream != nil { s.v3handler.process = api.NewProcess( config.Restream, config.IAM, ) } if config.Prometheus != nil { s.handler.prometheus = handler.NewPrometheus( config.Prometheus.HTTPHandler(), ) } if config.Profiling { s.handler.profiling = handler.NewProfiling() } if config.IPLimiter != nil { s.middleware.iplimit = mwiplimit.NewWithConfig(mwiplimit.Config{ Limiter: config.IPLimiter, }) } s.handler.ping = handler.NewPing() if config.RTMP != nil { s.v3handler.rtmp = api.NewRTMP( config.RTMP, ) } if config.SRT != nil { s.v3handler.srt = api.NewSRT( config.SRT, ) } if config.Config != nil { s.v3handler.config = api.NewConfig( config.Config, ) } if config.Sessions == nil { config.Sessions, _ = session.New(session.Config{}) if config.Sessions.Collector("hlsingress") == nil { return nil, fmt.Errorf("hlsingress session collector must be available") } if config.Sessions.Collector("hls") == nil { return nil, fmt.Errorf("hls session collector must be available") } if config.Sessions.Collector("http") == nil { return nil, fmt.Errorf("http session collector must be available") } } s.v3handler.session = api.NewSession( config.Sessions, config.IAM, ) s.middleware.log = mwlog.NewWithConfig(mwlog.Config{ Logger: s.logger, Status: func(code int, method, path string, size int64, ttfb time.Duration) { key := fmt.Sprintf("%d:%s:%s", code, method, path) s.metrics.lock.Lock() defer s.metrics.lock.Unlock() s.metrics.status[key]++ }, }) s.v3handler.widget = api.NewWidget(api.WidgetConfig{ Restream: config.Restream, Registry: config.Sessions, }) s.v3handler.resources = api.NewMetrics(api.MetricsConfig{ Metrics: config.Metrics, }) if config.Cluster != nil { handler, err := api.NewCluster(config.Cluster, config.IAM) if err != nil { return nil, fmt.Errorf("cluster handler: %w", err) } s.v3handler.cluster = handler } if middleware, err := mwcors.NewWithConfig(mwcors.Config{ Prefixes: corsPrefixes, }); err != nil { return nil, err } else { s.middleware.cors = middleware } s.handler.graph = api.NewGraph(resolver.Resolver{ Restream: config.Restream, Monitor: config.Metrics, LogBuffer: config.LogBuffer, IAM: config.IAM, }, "/api/graph/query") s.compress.encoding = config.Compress.Encoding s.compress.mimetypes = config.Compress.MimeTypes s.compress.minLength = config.Compress.MinLength s.router = echo.New() s.router.JSONSerializer = &GoJSONSerializer{} s.router.HTTPErrorHandler = errorhandler.HTTPErrorHandler s.router.Validator = validator.New() s.router.Use(s.middleware.log) s.router.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{ LogErrorFunc: func(c echo.Context, err error, stack []byte) error { rows := strings.Split(string(stack), "\n") s.logger.Error().WithField("stack", rows).Log("recovered from a panic") return nil }, })) s.router.HideBanner = true s.router.HidePort = true s.router.Logger.SetOutput(httplog.NewWrapper(s.logger)) if s.middleware.cors != nil { s.router.Use(s.middleware.cors) } s.router.Use(s.middleware.iam) s.router.Use(mwcompress.NewWithConfig(mwcompress.Config{ Level: mwcompress.BestSpeed, MinLength: config.Compress.MinLength, Schemes: config.Compress.Encoding, ContentTypes: config.Compress.MimeTypes, })) s.router.Use(mwsession.NewWithConfig(mwsession.Config{ HLSIngressCollector: config.Sessions.Collector("hlsingress"), HLSEgressCollector: config.Sessions.Collector("hls"), HTTPCollector: config.Sessions.Collector("http"), Skipper: func(c echo.Context) bool { // Exclude the API from the sessions path := c.Request().URL.Path if path == "/api" { return true } return strings.HasPrefix(path, "/api/") }, })) // Add static routes if path, target := config.Router.StaticRoute(); len(target) != 0 { group := s.router.Group(path) group.Use(middleware.AddTrailingSlashWithConfig(middleware.TrailingSlashConfig{ Skipper: func(c echo.Context) bool { return path != c.Request().URL.Path }, RedirectCode: 301, })) group.Use(middleware.StaticWithConfig(middleware.StaticConfig{ Skipper: middleware.DefaultSkipper, Root: target, Index: "index.html", IgnoreBase: true, })) } s.router.Use(mwredirect.NewWithConfig(mwredirect.Config{ Redirects: config.Router.FileRoutes(), })) for prefix, target := range config.Router.DirRoutes() { group := s.router.Group(prefix) group.Use(middleware.AddTrailingSlashWithConfig(middleware.TrailingSlashConfig{ Skipper: func(prefix string) func(c echo.Context) bool { return func(c echo.Context) bool { return prefix != c.Request().URL.Path } }(prefix), RedirectCode: 301, })) group.Use(middleware.StaticWithConfig(middleware.StaticConfig{ Skipper: middleware.DefaultSkipper, Root: target, Index: "index.html", IgnoreBase: true, })) } s.setRoutes() return s, nil } func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) } func (s *server) HTTPStatus() map[string]uint64 { status := map[string]uint64{} s.metrics.lock.Lock() defer s.metrics.lock.Unlock() maps.Copy(status, s.metrics.status) return status } func (s *server) setRoutes() { // API router grouo api := s.router.Group("/api") if s.middleware.iplimit != nil { api.Use(s.middleware.iplimit) } // The login endpoint should not be blocked by auth s.router.POST("/api/login", s.handler.jwt.Login) s.router.GET("/api/login/refresh", s.handler.jwt.Refresh) api.GET("", s.handler.about.About) // Swagger API documentation router group doc := s.router.Group("/api/swagger/*") doc.GET("", echoSwagger.WrapHandler) // Mount filesystems for _, filesystem := range s.filesystems { // Define a local variable because later in the loop we have a closure filesystem := filesystem mountpoint := filesystem.Mountpoint + "/*" if filesystem.Mountpoint == "/" { mountpoint = "/*" } fs := s.router.Group(mountpoint) fs.Use(mwmime.NewWithConfig(mwmime.Config{ MimeTypesFile: s.mimeTypesFile, MimeTypes: s.mimeTypes, DefaultContentType: filesystem.DefaultContentType, })) if filesystem.Cache != nil { mwcache := mwcache.NewWithConfig(mwcache.Config{ Cache: filesystem.Cache, }) fs.Use(mwcache) } if filesystem.middleware != nil { fs.Use(filesystem.middleware) } fs.GET("", filesystem.handler.GetFile) fs.HEAD("", filesystem.handler.GetFile) if filesystem.AllowWrite { fs.POST("", filesystem.handler.PutFile) fs.PUT("", filesystem.handler.PutFile) fs.DELETE("", filesystem.handler.DeleteFile) } s.logger.Info().WithFields(log.Fields{ "name": filesystem.Name, "mountpoint": filesystem.Mountpoint, }).Log("Mount filesystem") } // Prometheus metrics if s.handler.prometheus != nil { metrics := s.router.Group("/metrics") if s.middleware.iplimit != nil { metrics.Use(s.middleware.iplimit) } metrics.GET("", s.handler.prometheus.Metrics) } // Health check s.router.GET("/ping", s.handler.ping.Ping) // Profiling routes if s.profiling { prof := s.router.Group("/profiling") if s.middleware.iplimit != nil { prof.Use(s.middleware.iplimit) } s.handler.profiling.Register(prof) } // GraphQL graphql := api.Group("/graph") //graphql.Use(gzipMiddleware) graphql.GET("", s.handler.graph.Playground) graphql.POST("/query", s.handler.graph.Query) // APIv3 router group v3 := api.Group("/v3") //v3.Use(gzipMiddleware) s.setRoutesV3(v3) } func (s *server) setRoutesV3(v3 *echo.Group) { if s.v3handler.widget != nil { // The widget endpoint should not be blocked by auth s.router.GET("/api/v3/widget/process/:id", s.v3handler.widget.Get) } // v3 IAM if s.v3handler.iam != nil { v3.GET("/iam/user", s.v3handler.iam.ListIdentities) v3.GET("/iam/user/:name", s.v3handler.iam.GetIdentity) if !s.readOnly { v3.POST("/iam/user", s.v3handler.iam.AddIdentity) v3.PUT("/iam/user/:name", s.v3handler.iam.UpdateIdentity) v3.PUT("/iam/user/:name/policy", s.v3handler.iam.UpdateIdentityPolicies) v3.DELETE("/iam/user/:name", s.v3handler.iam.RemoveIdentity) } } // v3 Restreamer if s.v3handler.process != nil { v3.GET("/skills", s.v3handler.process.Skills) v3.GET("/skills/reload", s.v3handler.process.ReloadSkills) v3.GET("/process", s.v3handler.process.GetAll) v3.GET("/process/:id", s.v3handler.process.Get) v3.GET("/process/:id/config", s.v3handler.process.GetConfig) v3.GET("/process/:id/state", s.v3handler.process.GetState) v3.GET("/process/:id/report", s.v3handler.process.GetReport) v3.GET("/process/:id/metadata", s.v3handler.process.GetProcessMetadata) v3.GET("/process/:id/metadata/:key", s.v3handler.process.GetProcessMetadata) v3.GET("/metadata", s.v3handler.process.GetMetadata) v3.GET("/metadata/:key", s.v3handler.process.GetMetadata) if !s.readOnly { v3.POST("/process/probe", s.v3handler.process.ProbeConfig) v3.POST("/process/validate", s.v3handler.process.ValidateConfig) v3.GET("/process/:id/probe", s.v3handler.process.Probe) v3.POST("/process", s.v3handler.process.Add) v3.PUT("/process/:id", s.v3handler.process.Update) v3.DELETE("/process/:id", s.v3handler.process.Delete) v3.PUT("/process/:id/command", s.v3handler.process.Command) v3.PUT("/process/:id/report", s.v3handler.process.SetReport) v3.PUT("/process/:id/metadata/:key", s.v3handler.process.SetProcessMetadata) v3.PUT("/metadata/:key", s.v3handler.process.SetMetadata) } // v3 Playout v3.GET("/process/:id/playout/:inputid/status", s.v3handler.process.PlayoutStatus) v3.GET("/process/:id/playout/:inputid/reopen", s.v3handler.process.PlayoutReopenInput) v3.GET("/process/:id/playout/:inputid/keyframe/*", s.v3handler.process.PlayoutKeyframe) v3.GET("/process/:id/playout/:inputid/errorframe/encode", s.v3handler.process.PlayoutEncodeErrorframe) if !s.readOnly { v3.PUT("/process/:id/playout/:inputid/errorframe/*", s.v3handler.process.PlayoutSetErrorframe) v3.POST("/process/:id/playout/:inputid/errorframe/*", s.v3handler.process.PlayoutSetErrorframe) v3.PUT("/process/:id/playout/:inputid/stream", s.v3handler.process.PlayoutSetStream) } // v3 Report v3.GET("/report/process", s.v3handler.process.SearchReportHistory) } // v3 Filesystems fshandlers := map[string]api.FSConfig{} for _, fs := range s.filesystems { fshandlers[fs.Name] = api.FSConfig{ Type: fs.Filesystem.Type(), Mountpoint: fs.Mountpoint, Handler: fs.apihandler, } } handler := api.NewFS(fshandlers) v3.GET("/fs", handler.List) v3.PUT("/fs", handler.FileOperation) v3.GET("/fs/:storage", handler.ListFiles) v3.GET("/fs/:storage/*", handler.GetFile, mwmime.NewWithConfig(mwmime.Config{ MimeTypesFile: s.mimeTypesFile, DefaultContentType: "application/data", })) v3.HEAD("/fs/:storage/*", handler.GetFile, mwmime.NewWithConfig(mwmime.Config{ MimeTypesFile: s.mimeTypesFile, DefaultContentType: "application/data", })) if !s.readOnly { v3.PUT("/fs/:storage/*", handler.PutFile) v3.DELETE("/fs/:storage", handler.DeleteFiles) v3.DELETE("/fs/:storage/*", handler.DeleteFile) } // v3 RTMP if s.v3handler.rtmp != nil { v3.GET("/rtmp", s.v3handler.rtmp.ListChannels) } // v3 SRT if s.v3handler.srt != nil { v3.GET("/srt", s.v3handler.srt.ListChannels) } // v3 Config if s.v3handler.config != nil { v3.GET("/config", s.v3handler.config.Get) if !s.readOnly { v3.PUT("/config", s.v3handler.config.Set) v3.GET("/config/reload", s.v3handler.config.Reload) } } // v3 Session if s.v3handler.session != nil { v3.GET("/session", s.v3handler.session.Summary) v3.GET("/session/active", s.v3handler.session.Active) v3.PUT("/session/token/:username", s.v3handler.session.CreateToken) } // v3 Cluster if s.v3handler.cluster != nil { v3.GET("/cluster", s.v3handler.cluster.About) v3.GET("/cluster/healthy", s.v3handler.cluster.Healthy) v3.GET("/cluster/snapshot", s.v3handler.cluster.GetSnapshot) v3.GET("/cluster/db/process", s.v3handler.cluster.StoreListProcesses) v3.GET("/cluster/db/process/:id", s.v3handler.cluster.StoreGetProcess) v3.GET("/cluster/db/user", s.v3handler.cluster.StoreListIdentities) v3.GET("/cluster/db/user/:name", s.v3handler.cluster.StoreGetIdentity) v3.GET("/cluster/db/policies", s.v3handler.cluster.StoreListPolicies) v3.GET("/cluster/db/locks", s.v3handler.cluster.StoreListLocks) v3.GET("/cluster/db/kv", s.v3handler.cluster.StoreListKV) v3.GET("/cluster/db/map/process", s.v3handler.cluster.StoreGetProcessNodeMap) v3.GET("/cluster/db/node", s.v3handler.cluster.StoreListNodes) v3.GET("/cluster/iam/user", s.v3handler.cluster.IAMIdentityList) v3.GET("/cluster/iam/user/:name", s.v3handler.cluster.IAMIdentityGet) v3.GET("/cluster/iam/policies", s.v3handler.cluster.IAMPolicyList) v3.GET("/cluster/process", s.v3handler.cluster.ProcessList) v3.GET("/cluster/process/:id", s.v3handler.cluster.ProcessGet) v3.GET("/cluster/process/:id/metadata", s.v3handler.cluster.ProcessGetMetadata) v3.GET("/cluster/process/:id/metadata/:key", s.v3handler.cluster.ProcessGetMetadata) v3.GET("/cluster/node", s.v3handler.cluster.NodeList) v3.GET("/cluster/node/:id", s.v3handler.cluster.NodeGet) v3.GET("/cluster/node/:id/files", s.v3handler.cluster.NodeGetMedia) v3.GET("/cluster/node/:id/fs/:storage", s.v3handler.cluster.NodeFSListFiles) v3.GET("/cluster/node/:id/fs/:storage/*", s.v3handler.cluster.NodeFSGetFile) v3.GET("/cluster/node/:id/process", s.v3handler.cluster.NodeListProcesses) v3.GET("/cluster/node/:id/version", s.v3handler.cluster.NodeGetVersion) v3.GET("/cluster/node/:id/state", s.v3handler.cluster.NodeGetState) v3.GET("/cluster/fs/:storage", s.v3handler.cluster.FilesystemListFiles) v3.POST("/cluster/events", s.v3handler.cluster.Events) if !s.readOnly { v3.PUT("/cluster/transfer/:id", s.v3handler.cluster.TransferLeadership) v3.PUT("/cluster/leave", s.v3handler.cluster.Leave) v3.POST("/cluster/process", s.v3handler.cluster.ProcessAdd) v3.POST("/cluster/process/probe", s.v3handler.cluster.ProcessProbeConfig) v3.PUT("/cluster/process/:id", s.v3handler.cluster.ProcessUpdate) v3.GET("/cluster/process/:id/probe", s.v3handler.cluster.ProcessProbe) v3.DELETE("/cluster/process/:id", s.v3handler.cluster.ProcessDelete) v3.PUT("/cluster/process/:id/command", s.v3handler.cluster.ProcessSetCommand) v3.PUT("/cluster/process/:id/metadata/:key", s.v3handler.cluster.ProcessSetMetadata) v3.PUT("/cluster/reallocation", s.v3handler.cluster.Reallocation) v3.DELETE("/cluster/node/:id/fs/:storage/*", s.v3handler.cluster.NodeFSDeleteFile) v3.PUT("/cluster/node/:id/fs/:storage/*", s.v3handler.cluster.NodeFSPutFile) v3.PUT("/cluster/node/:id/state", s.v3handler.cluster.NodeSetState) v3.PUT("/cluster/iam/reload", s.v3handler.cluster.IAMReload) v3.POST("/cluster/iam/user", s.v3handler.cluster.IAMIdentityAdd) v3.PUT("/cluster/iam/user/:name", s.v3handler.cluster.IAMIdentityUpdate) v3.PUT("/cluster/iam/user/:name/policy", s.v3handler.cluster.IAMIdentityUpdatePolicies) v3.DELETE("/cluster/iam/user/:name", s.v3handler.cluster.IAMIdentityRemove) } } // v3 Log if s.v3handler.log != nil { v3.GET("/log", s.v3handler.log.Log) } // v3 Metrics if s.v3handler.resources != nil { v3.GET("/metrics", s.v3handler.resources.Describe) v3.POST("/metrics", s.v3handler.resources.Metrics) } // v3 Events if s.v3handler.events != nil { v3.POST("/events", s.v3handler.events.Events) } }