diff --git a/router-tests/testenv/testenv.go b/router-tests/testenv/testenv.go index 6dd813f9d6..ac36b4c557 100644 --- a/router-tests/testenv/testenv.go +++ b/router-tests/testenv/testenv.go @@ -828,6 +828,7 @@ func configureRouter(listenerAddr string, testConfig *Config, routerConfig *node core.WithGraphApiToken(graphApiToken), core.WithDevelopmentMode(true), core.WithPlayground(true), + core.WithPlaygroundConfig(config.PlaygroundConfig{Enabled: true}), core.WithEngineExecutionConfig(engineExecutionConfig), core.WithSecurityConfig(cfg.SecurityConfiguration), core.WithCacheControlPolicy(cfg.CacheControl), diff --git a/router/cmd/instance.go b/router/cmd/instance.go index c18af92004..4828613370 100644 --- a/router/cmd/instance.go +++ b/router/cmd/instance.go @@ -114,6 +114,7 @@ func NewRouter(ctx context.Context, params Params, additionalOptions ...core.Opt core.WithGraphQLPath(cfg.GraphQLPath), core.WithModulesConfig(cfg.Modules), core.WithGracePeriod(cfg.GracePeriod), + core.WithPlaygroundConfig(cfg.PlaygroundConfig), core.WithPlaygroundPath(cfg.PlaygroundPath), core.WithHealthCheckPath(cfg.HealthCheckPath), core.WithLivenessCheckPath(cfg.LivenessCheckPath), diff --git a/router/core/graph_server.go b/router/core/graph_server.go index fa30d261c2..a406006274 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -235,8 +235,8 @@ func newGraphServer(ctx context.Context, r *Router, routerConfig *nodev1.RouterC // We mount the playground once here when we don't have a conflict with the websocket handler // If we have a conflict, we mount the playground during building the individual muxes - if s.playgroundHandler != nil && s.graphqlPath != s.playgroundPath { - httpRouter.Get(r.playgroundPath, s.playgroundHandler(nil).ServeHTTP) + if s.playgroundHandler != nil && s.graphqlPath != s.playgroundConfig.Path { + httpRouter.Get(r.playgroundConfig.Path, s.playgroundHandler(nil).ServeHTTP) } httpRouter.Get(s.healthCheckPath, r.healthcheck.Liveness()) @@ -1049,7 +1049,7 @@ func (s *graphServer) buildGraphMux(ctx context.Context, // When the playground path is equal to the graphql path, we need to handle // ws upgrades and html requests on the same route. - if s.playground && s.graphqlPath == s.playgroundPath { + if s.playgroundConfig.Enabled && s.graphqlPath == s.playgroundConfig.Path { httpRouter.Use(s.playgroundHandler, wsMiddleware) } else { httpRouter.Use(wsMiddleware) diff --git a/router/core/router.go b/router/core/router.go index ac37f2fef7..c4612aa25f 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -177,6 +177,7 @@ type ( healthCheckPath string readinessCheckPath string livenessCheckPath string + playgroundConfig config.PlaygroundConfig cacheControlPolicy config.CacheControlPolicy routerConfigPollerConfig *RouterConfigPollerConfig cdnConfig config.CDNConfiguration @@ -258,8 +259,18 @@ func NewRouter(opts ...Option) (*Router, error) { r.graphqlWebURL = r.graphqlPath } - if r.playgroundPath == "" { - r.playgroundPath = "/" + // this is set via the deprecated method + if !r.playground { + r.playgroundConfig.Enabled = r.playground + r.logger.Warn("The playground_enabled option is deprecated. Use the playground.enabled option in the config instead.") + } + if r.playgroundPath != "" && r.playgroundPath != "/" { + r.playgroundConfig.Path = r.playgroundPath + r.logger.Warn("The playground_path option is deprecated. Use the playground.path option in the config instead.") + } + + if r.playgroundConfig.Path == "" { + r.playgroundConfig.Path = "/" } if r.instanceID == "" { @@ -598,7 +609,7 @@ func (r *Router) newServer(ctx context.Context, cfg *nodev1.RouterConfig) error func (r *Router) listenAndServe(cfg *nodev1.RouterConfig) error { r.logger.Info("Server listening and serving", zap.String("listen_addr", r.listenAddr), - zap.Bool("playground", r.playground), + zap.Bool("playground", r.playgroundConfig.Enabled), zap.Bool("introspection", r.introspection), zap.String("config_version", cfg.GetVersion()), ) @@ -854,15 +865,16 @@ func (r *Router) bootstrap(ctx context.Context) error { debug.ReportMemoryUsage(ctx, r.logger) } - if r.playground { - playgroundUrl, err := url.JoinPath(r.baseURL, r.playgroundPath) + if r.playgroundConfig.Enabled { + playgroundUrl, err := url.JoinPath(r.baseURL, r.playgroundConfig.Path) if err != nil { return fmt.Errorf("failed to join playground url: %w", err) } r.logger.Info("Serving GraphQL playground", zap.String("url", playgroundUrl)) r.playgroundHandler = graphiql.NewPlayground(&graphiql.PlaygroundOptions{ - Html: graphiql.PlaygroundHTML(), - GraphqlURL: r.graphqlWebURL, + Html: graphiql.PlaygroundHTML(), + GraphqlURL: r.graphqlWebURL, + ConcurrencyLimit: int64(r.playgroundConfig.ConcurrencyLimit), }) } @@ -1128,7 +1140,7 @@ func (r *Router) Start(ctx context.Context) error { return err } - if r.playground { + if r.playgroundConfig.Enabled { graphqlEndpointURL, err := url.JoinPath(r.baseURL, r.graphqlPath) if err != nil { return fmt.Errorf("failed to join graphql endpoint url: %w", err) @@ -1381,6 +1393,13 @@ func WithPlaygroundPath(p string) Option { } } +// WithPlaygroundPath sets the path where the GraphQL Playground is served. +func WithPlaygroundConfig(c config.PlaygroundConfig) Option { + return func(r *Router) { + r.playgroundConfig = c + } +} + // WithConfigPoller sets the poller client to fetch the router config. If not set, WithConfigPollerConfig should be set. func WithConfigPoller(cf configpoller.ConfigPoller) Option { return func(r *Router) { diff --git a/router/internal/graphiql/playgroundhandler.go b/router/internal/graphiql/playgroundhandler.go index 3c72a77cae..2ee3c63ed5 100644 --- a/router/internal/graphiql/playgroundhandler.go +++ b/router/internal/graphiql/playgroundhandler.go @@ -1,30 +1,52 @@ package graphiql import ( + "bytes" + "golang.org/x/sync/semaphore" "net/http" "strconv" "strings" ) type PlaygroundOptions struct { - Html string - GraphqlURL string + Html string + GraphqlURL string + ConcurrencyLimit int64 } type Playground struct { - next http.Handler - opts *PlaygroundOptions + next http.Handler + opts *PlaygroundOptions + templateBytes []byte + sem *semaphore.Weighted } +var ( + defaultLimitUsage = int64(10) +) + func NewPlayground(opts *PlaygroundOptions) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { - return &Playground{ + limit := opts.ConcurrencyLimit + if limit == 0 { + limit = defaultLimitUsage + } + p := &Playground{ next: next, opts: opts, + sem: semaphore.NewWeighted(limit), } + p.initPlayground() + return p } } +func (p *Playground) initPlayground() { + tpl := strings.Replace(p.opts.Html, "{{graphqlURL}}", p.opts.GraphqlURL, -1) + play := []byte(tpl) + p.templateBytes = play +} + func (p *Playground) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Only serve the playground if the request is for text/html // if not, just pass through to the next handler @@ -35,12 +57,14 @@ func (p *Playground) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - tpl := strings.Replace(p.opts.Html, "{{graphqlURL}}", p.opts.GraphqlURL, -1) - resp := []byte(tpl) - + if err := p.sem.Acquire(r.Context(), 1); err != nil { + http.Error(w, "Too many requests", http.StatusTooManyRequests) + return + } + defer p.sem.Release(1) // Ensure the semaphore slot is released w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Content-Length", strconv.Itoa(len(resp))) + w.Header().Set("Content-Length", strconv.Itoa(len(p.templateBytes))) w.WriteHeader(http.StatusOK) - _, _ = w.Write(resp) + _, _ = bytes.NewBuffer(p.templateBytes).WriteTo(w) } diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index ecf0317f99..fac97f09d8 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -805,6 +805,7 @@ type Config struct { ListenAddr string `yaml:"listen_addr" envDefault:"localhost:3002" env:"LISTEN_ADDR"` ControlplaneURL string `yaml:"controlplane_url" envDefault:"https://cosmo-cp.wundergraph.com" env:"CONTROLPLANE_URL"` + PlaygroundConfig PlaygroundConfig `yaml:"playground,omitempty"` PlaygroundEnabled bool `yaml:"playground_enabled" envDefault:"true" env:"PLAYGROUND_ENABLED"` IntrospectionEnabled bool `yaml:"introspection_enabled" envDefault:"true" env:"INTROSPECTION_ENABLED"` QueryPlansEnabled bool `yaml:"query_plans_enabled" envDefault:"true" env:"QUERY_PLANS_ENABLED"` @@ -851,6 +852,12 @@ type Config struct { ClientHeader ClientHeader `yaml:"client_header"` } +type PlaygroundConfig struct { + Enabled bool `yaml:"enabled" envDefault:"true" env:"PLAYGROUND_ENABLED"` + Path string `yaml:"path" envDefault:"/" env:"PLAYGROUND_PATH"` + ConcurrencyLimit int `yaml:"concurrency_limit,omitempty" envDefault:"10" env:"PLAYGROUND_CONCURRENCY_LIMIT"` +} + type LoadResult struct { Config Config DefaultLoaded bool diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 0219476890..1c3136aeb3 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -1075,10 +1075,36 @@ "default": "https://cosmo-cp.wundergraph.com", "format": "http-url" }, + "playground": { + "type": "object", + "description": "The configuration for the playground. The playground is a web-based GraphQL IDE that allows you to interact with the GraphQL API.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the GraphQL Playground. The GraphQL Playground is a web-based GraphQL IDE that allows you to interact with the GraphQL API. The default value is true. If the value is false, the GraphQL Playground is disabled.", + "default": true + }, + "path": { + "type": "string", + "format": "x-uri", + "default": "/", + "description": "The path of the GraphQL Playground. The GraphQL Playground is a web-based GraphQL IDE that allows you to interact with the GraphQL API. The default value is '/'." + }, + "concurrency_limit": { + "type": "integer", + "description": "The concurrency limit for loading the playground. This shouldn't impact normal usage.", + "default": 10, + "minimum": 1 + } + } + }, "playground_enabled": { "type": "boolean", "description": "Enable the GraphQL Playground. The GraphQL Playground is a web-based GraphQL IDE that allows you to interact with the GraphQL API. The default value is true. If the value is false, the GraphQL Playground is disabled.", - "default": true + "default": true, + "deprecated": true, + "deprecationMessage": "playground_enabled is deprecated. Please use the playground.enabled configuration instead." }, "introspection_enabled": { "type": "boolean", @@ -1159,7 +1185,9 @@ "type": "string", "format": "x-uri", "default": "/", - "description": "The path of the GraphQL Playground. The GraphQL Playground is a web-based GraphQL IDE that allows you to interact with the GraphQL API. The default value is '/'." + "description": "The path of the GraphQL Playground. The GraphQL Playground is a web-based GraphQL IDE that allows you to interact with the GraphQL API. The default value is '/'.", + "deprecated": true, + "deprecationMessage": "playground_path is deprecated. Please use the playground.path configuration instead." }, "file_upload": { "type": "object", diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index a4a4027494..7b38221ab7 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -11,6 +11,10 @@ listen_addr: "localhost:3002" controlplane_url: "https://cosmo-cp.wundergraph.com" playground_enabled: true playground_path: "/" +playground: + enabled: false + path: "/my-playground" + concurrency_limit: 1500 introspection_enabled: true json_log: true shutdown_delay: 15s diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 8215fe3cf8..cc2a9afecd 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -160,6 +160,11 @@ }, "ListenAddr": "localhost:3002", "ControlplaneURL": "https://cosmo-cp.wundergraph.com", + "PlaygroundConfig": { + "Enabled": true, + "Path": "/", + "ConcurrencyLimit": 10 + }, "PlaygroundEnabled": true, "IntrospectionEnabled": true, "QueryPlansEnabled": true, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index dc390712d2..3beff6cec7 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -321,6 +321,11 @@ }, "ListenAddr": "localhost:3002", "ControlplaneURL": "https://cosmo-cp.wundergraph.com", + "PlaygroundConfig": { + "Enabled": false, + "Path": "/my-playground", + "ConcurrencyLimit": 1500 + }, "PlaygroundEnabled": true, "IntrospectionEnabled": true, "QueryPlansEnabled": true,