blob: 76bf23e2d957d475f593a7933a087dc593077a0d [file] [log] [blame]
// The goldfrontend executable is the process that exposes a RESTful API used by the JS frontend.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"html/template"
"math/rand"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
gstorage "google.golang.org/api/storage/v1"
"google.golang.org/grpc"
"go.skia.org/infra/go/alogin"
"go.skia.org/infra/go/alogin/proxylogin"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/common"
"go.skia.org/infra/go/gerrit"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/metrics2"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/tracing/loggingtracer"
"go.skia.org/infra/golden/go/clstore"
"go.skia.org/infra/golden/go/code_review"
"go.skia.org/infra/golden/go/code_review/gerrit_crs"
"go.skia.org/infra/golden/go/code_review/github_crs"
"go.skia.org/infra/golden/go/config"
"go.skia.org/infra/golden/go/ignore"
"go.skia.org/infra/golden/go/ignore/sqlignorestore"
"go.skia.org/infra/golden/go/publicparams"
"go.skia.org/infra/golden/go/search"
"go.skia.org/infra/golden/go/sql"
"go.skia.org/infra/golden/go/storage"
"go.skia.org/infra/golden/go/tracing"
"go.skia.org/infra/golden/go/web"
"go.skia.org/infra/golden/go/web/frontend"
)
const (
// Arbitrarily picked.
maxSQLConnections = 32
)
type frontendServerConfig struct {
config.Common
// Force the user to be authenticated for all requests.
ForceLogin bool `json:"force_login"`
// Configuration settings that will get passed to the frontend (see modules/settings.ts)
FrontendConfig frontendConfig `json:"frontend"`
// If this instance is simply a mirror of another instance's data.
IsPublicView bool `json:"is_public_view"`
// MaterializedViewCorpora is the optional list of corpora that should have a materialized
// view created and refreshed to speed up search results.
MaterializedViewCorpora []string `json:"materialized_view_corpora" optional:"true"`
// If non empty, this map of rules will be applied to traces to see if they can be showed on
// this instance.
PubliclyAllowableParams publicparams.MatchingRules `json:"publicly_allowed_params" optional:"true"`
// Path to a directory with static assets that should be served to the frontend (JS, CSS, etc.).
ResourcesPath string `json:"resources_path"`
}
// IsAuthoritative indicates that this instance can write to known_hashes, update CL statuses, etc.
func (fsc *frontendServerConfig) IsAuthoritative() bool {
return !fsc.Local && !fsc.IsPublicView
}
type frontendConfig struct {
BaseRepoURL string `json:"baseRepoURL"`
DefaultCorpus string `json:"defaultCorpus"`
Title string `json:"title"`
CustomTriagingDisallowedMsg string `json:"customTriagingDisallowedMsg,omitempty" optional:"true"`
IsPublic bool `json:"isPublic"`
}
func main() {
// Command line flags.
var (
commonInstanceConfig = flag.String("common_instance_config", "", "Path to the json5 file containing the configuration that needs to be the same across all services for a given instance.")
thisConfig = flag.String("config", "", "Path to the json5 file containing the configuration specific to baseline server.")
hang = flag.Bool("hang", false, "Stop and do nothing after reading the flags. Good for debugging containers.")
logSQLQueries = flag.Bool("log_sql_queries", false, "Log all SQL statements. For debugging only; do not use in production.")
)
// Parse the flags, so we can load the configuration files.
flag.Parse()
if *hang {
sklog.Info("Hanging")
select {}
}
// Load configuration from common and instance-specific JSON files.
fsc := mustLoadFrontendServerConfig(commonInstanceConfig, thisConfig)
// Speculative memory usage fix? https://github.com/googleapis/google-cloud-go/issues/375
grpc.EnableTracing = false
if err := tracing.Initialize(0.01, fsc.SQLDatabaseName); err != nil {
sklog.Fatalf("Could not initialize tracing: %s", err)
}
// Log traces and their durations via sklog.Info() when running locally.
if fsc.Local {
loggingtracer.Initialize()
}
// Needed to use TimeSortableKey(...) which relies on an RNG. See docs there.
rand.Seed(time.Now().UnixNano())
// Initialize service.
_, appName := filepath.Split(os.Args[0])
common.InitWithMust(
appName,
common.PrometheusOpt(&fsc.PromPort),
)
ctx := context.Background()
mustStartDebugServer(fsc)
client := mustMakeAuthenticatedHTTPClient(fsc.Local)
sqlDB := mustInitSQLDatabase(ctx, fsc, *logSQLQueries)
gsClient := mustMakeGCSClient(ctx, fsc, client)
publiclyViewableParams := mustMakePubliclyViewableParams(fsc)
ignoreStore := mustMakeIgnoreStore(ctx, sqlDB)
reviewSystems := mustInitializeReviewSystems(fsc, client)
s2a := mustLoadSearchAPI(ctx, fsc, sqlDB, publiclyViewableParams, reviewSystems)
plogin := proxylogin.NewWithDefaults()
handlers := mustMakeWebHandlers(ctx, fsc, sqlDB, gsClient, ignoreStore, reviewSystems, s2a, plogin)
rootRouter := mustMakeRootRouter(fsc, handlers, plogin)
// Start the server
sklog.Infof("Serving on http://127.0.0.1" + fsc.ReadyPort)
sklog.Fatal(http.ListenAndServe(fsc.ReadyPort, rootRouter))
}
func mustLoadSearchAPI(ctx context.Context, fsc *frontendServerConfig, sqlDB *pgxpool.Pool, publiclyViewableParams publicparams.Matcher, systems []clstore.ReviewSystem) *search.Impl {
templates := map[string]string{}
for _, crs := range systems {
templates[crs.ID] = crs.URLTemplate
}
cacheClient, err := fsc.GetCacheClient(ctx)
if err != nil {
sklog.Fatalf("Error while trying to create a new cache client: %v", err)
}
if cacheClient == nil {
sklog.Fatalf("Cache is not configured correctly for this instance.")
}
s2a := search.New(sqlDB, fsc.WindowSize, cacheClient, fsc.CachingCorpora)
s2a.SetDatabaseType(fsc.SQLDatabaseType)
s2a.SetReviewSystemTemplates(templates)
sklog.Infof("SQL Search loaded with CRS templates %s", templates)
err = s2a.StartCacheProcess(ctx, 5*time.Minute, fsc.WindowSize)
if err != nil {
sklog.Fatalf("Cannot load caches for search2 backend: %s", err)
}
if fsc.SQLDatabaseType != config.Spanner {
if err := s2a.StartMaterializedViews(ctx, fsc.MaterializedViewCorpora, 5*time.Minute); err != nil {
sklog.Fatalf("Cannot create materialized views %s: %s", fsc.MaterializedViewCorpora, err)
}
}
if fsc.IsPublicView {
if err := s2a.StartApplyingPublicParams(ctx, publiclyViewableParams, 5*time.Minute); err != nil {
sklog.Fatalf("Could not apply public params: %s", err)
}
sklog.Infof("Public params applied to search2")
}
return s2a
}
// mustLoadFrontendServerConfig parses the common and instance-specific JSON configuration files.
func mustLoadFrontendServerConfig(commonInstanceConfig *string, thisConfig *string) *frontendServerConfig {
var fsc frontendServerConfig
if err := config.LoadFromJSON5(&fsc, commonInstanceConfig, thisConfig); err != nil {
sklog.Fatalf("Reading config: %s", err)
}
sklog.Infof("Loaded config %#v", fsc)
return &fsc
}
// mustStartDebugServer starts an internal HTTP server for debugging purposes if requested.
func mustStartDebugServer(fsc *frontendServerConfig) {
if fsc.DebugPort != "" {
go func() {
// Sample usage:
// $ kubectl port-forward --address 0.0.0.0 gold-skia-infra-frontend-xxxxxxxxxx-yyyyy 8000:7001
sklog.Infof("Internal server on http://127.0.0.1" + fsc.DebugPort)
httputils.ServePprof(fsc.DebugPort)
}()
}
}
// mustMakeAuthenticatedHTTPClient returns an http.Client with the credentials required by the
// services that Gold communicates with.
func mustMakeAuthenticatedHTTPClient(local bool) *http.Client {
// Get the token source for the service account with access to the services
// we need to operate.
tokenSource, err := google.DefaultTokenSource(context.TODO(), auth.ScopeUserinfoEmail, gstorage.CloudPlatformScope, auth.ScopeGerrit)
if err != nil {
sklog.Fatalf("Failed to authenticate service account: %s", err)
}
return httputils.DefaultClientConfig().WithTokenSource(tokenSource).Client()
}
// crdbLogger logs all SQL statements sent to the database.
type crdbLogger struct{}
func (l crdbLogger) Log(ctx context.Context, level pgx.LogLevel, msg string, data map[string]interface{}) {
sklog.Infof("[pgxpool %s] %q\n%+v\n", level, msg, data)
}
// mustInitSQLDatabase initializes a SQL database. If there are any errors, it will panic via
// sklog.Fatal.
func mustInitSQLDatabase(ctx context.Context, fsc *frontendServerConfig, logSQLQueries bool) *pgxpool.Pool {
if fsc.SQLDatabaseName == "" {
sklog.Fatalf("Must have SQL Database Information")
}
url := sql.GetConnectionURL(fsc.SQLConnection, fsc.SQLDatabaseName)
conf, err := pgxpool.ParseConfig(url)
if err != nil {
sklog.Fatalf("error getting postgres config %s: %s", url, err)
}
if logSQLQueries && fsc.Local {
conf.ConnConfig.Logger = crdbLogger{}
}
conf.MaxConns = maxSQLConnections
db, err := pgxpool.ConnectConfig(ctx, conf)
if err != nil {
sklog.Fatalf("error connecting to the database: %s", err)
}
sklog.Infof("Connected to SQL database %s", fsc.SQLDatabaseName)
return db
}
// mustMakeGCSClient returns a storage.GCSClient that uses the given http.Client. If the Gold
// instance is not authoritative (e.g. when running locally) the client won't actually write any
// files.
func mustMakeGCSClient(ctx context.Context, fsc *frontendServerConfig, client *http.Client) storage.GCSClient {
gsClientOpt := storage.GCSClientOptions{
Bucket: fsc.GCSBucket,
KnownHashesGCSPath: fsc.KnownHashesGCSPath,
Dryrun: !fsc.IsAuthoritative(),
}
gsClient, err := storage.NewGCSClient(ctx, client, gsClientOpt)
if err != nil {
sklog.Fatalf("Unable to create GCSClient: %s", err)
}
return gsClient
}
// mustMakePubliclyViewableParams validates and computes a publicparams.Matcher from the publicly
// allowed params specified in the JSON configuration files.
func mustMakePubliclyViewableParams(fsc *frontendServerConfig) publicparams.Matcher {
var publiclyViewableParams publicparams.Matcher
var err error
// Load the publiclyViewable params if configured and disable querying for issues.
if len(fsc.PubliclyAllowableParams) > 0 {
if publiclyViewableParams, err = publicparams.MatcherFromRules(fsc.PubliclyAllowableParams); err != nil {
sklog.Fatalf("Could not load list of public params: %s", err)
}
}
// Check if this is public instance. If so, make sure we have a non-nil Matcher.
if fsc.IsPublicView && publiclyViewableParams == nil {
sklog.Fatal("A non-empty map of publiclyViewableParams must be provided if is public view.")
}
return publiclyViewableParams
}
// mustMakeIgnoreStore returns a new ignore.Store and starts a monitoring routine that counts the
// the number of expired ignore rules and exposes this as a metric.
func mustMakeIgnoreStore(ctx context.Context, db *pgxpool.Pool) ignore.Store {
ignoreStore := sqlignorestore.New(db)
if err := ignore.StartMetrics(ctx, ignoreStore, 5*time.Minute); err != nil {
sklog.Fatalf("Failed to start monitoring for expired ignore rules: %s", err)
}
return ignoreStore
}
// mustInitializeReviewSystems validates and instantiates one clstore.ReviewSystem for each CRS
// specified via the JSON configuration files.
func mustInitializeReviewSystems(fsc *frontendServerConfig, hc *http.Client) []clstore.ReviewSystem {
rs := make([]clstore.ReviewSystem, 0, len(fsc.CodeReviewSystems))
for _, cfg := range fsc.CodeReviewSystems {
var crs code_review.Client
if cfg.Flavor == "gerrit" {
if cfg.GerritURL == "" {
sklog.Fatal("You must specify gerrit_url")
return nil
}
gerritClient, err := gerrit.NewGerrit(cfg.GerritURL, hc)
if err != nil {
sklog.Fatalf("Could not create gerrit client for %s", cfg.GerritURL)
return nil
}
crs = gerrit_crs.New(gerritClient)
} else if cfg.Flavor == "github" {
if cfg.GitHubRepo == "" || cfg.GitHubCredPath == "" {
sklog.Fatal("You must specify github_repo and github_cred_path")
return nil
}
gBody, err := os.ReadFile(cfg.GitHubCredPath)
if err != nil {
sklog.Fatalf("Couldn't find githubToken in %s: %s", cfg.GitHubCredPath, err)
return nil
}
gToken := strings.TrimSpace(string(gBody))
githubTS := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: gToken})
c := httputils.DefaultClientConfig().With2xxOnly().WithTokenSource(githubTS).Client()
crs = github_crs.New(c, cfg.GitHubRepo)
} else {
sklog.Fatalf("CRS flavor %s not supported.", cfg.Flavor)
return nil
}
rs = append(rs, clstore.ReviewSystem{
ID: cfg.ID,
Client: crs,
URLTemplate: cfg.URLTemplate,
})
}
return rs
}
// mustMakeWebHandlers returns a new web.Handlers.
func mustMakeWebHandlers(ctx context.Context, fsc *frontendServerConfig, db *pgxpool.Pool, gsClient storage.GCSClient, ignoreStore ignore.Store, reviewSystems []clstore.ReviewSystem, s2a search.API, alogin alogin.Login) *web.Handlers {
handlers, err := web.NewHandlers(web.HandlersConfig{
DB: db,
GCSClient: gsClient,
IgnoreStore: ignoreStore,
ReviewSystems: reviewSystems,
Search2API: s2a,
WindowSize: fsc.WindowSize,
GroupingParamKeysByCorpus: fsc.GroupingParamKeysByCorpus,
}, web.FullFrontEnd, alogin)
if err != nil {
sklog.Fatalf("Failed to initialize web handlers: %s", err)
}
handlers.StartCacheWarming(ctx)
return handlers
}
// mustMakeRootRouter returns a chi.Router that can be used to serve Gold's web UI and JSON API.
func mustMakeRootRouter(fsc *frontendServerConfig, handlers *web.Handlers, plogin alogin.Login) chi.Router {
rootRouter := chi.NewRouter()
rootRouter.HandleFunc("/healthz", httputils.ReadyHandleFunc)
// loggedRouter contains all the endpoints that are logged. See the call below to
// LoggingGzipRequestResponse.
loggedRouter := chi.NewRouter()
loggedRouter.HandleFunc("/_/login/status", alogin.LoginStatusHandler(plogin))
// JSON endpoints.
addAuthenticatedJSONRoutes(loggedRouter, fsc, handlers, plogin)
addUnauthenticatedJSONRoutes(rootRouter, fsc, handlers)
// Routes to serve the UI, static assets, etc.
addUIRoutes(loggedRouter, fsc, handlers, plogin)
// set up the app router that might be authenticated and logs almost everything.
appRouter := chi.NewRouter()
// Images should not be served gzipped as PNGs typically have zlib compression anyway.
appRouter.Get("/img/*", handlers.ImageHandler)
appRouter.Handle("/*", httputils.LoggingGzipRequestResponse(loggedRouter))
appHandler := http.Handler(appRouter)
// The appHandler contains all application specific routes that are have logging and
// authentication configured. Now we wrap it into the router that is exposed to the host
// (aka the K8s container) which requires that some routes are never logged or authenticated.
rootRouter.Handle("/*", appHandler)
return rootRouter
}
// addUIRoutes adds the necessary routes to serve Gold's web pages and static assets such as JS and
// CSS bundles, static images (digest and diff images are handled elsewhere), etc.
func addUIRoutes(router chi.Router, fsc *frontendServerConfig, handlers *web.Handlers, plogin alogin.Login) {
// Serve static assets (JS and CSS bundles, images, etc.).
//
// Note that this includes the raw HTML templates (e.g. /dist/byblame.html) with unpopulated
// placeholders such as {{.Title}}. These aren't used directly by client code. We should probably
// unexpose them and only serve the JS/CSS bundles from this route (and any other static assets
// such as the favicon).
router.Handle("/dist/*", http.StripPrefix("/dist/", http.HandlerFunc(makeResourceHandler(fsc.ResourcesPath))))
var templates *template.Template
loadTemplates := func() {
templates = template.Must(template.New("").ParseGlob(filepath.Join(fsc.ResourcesPath, "*.html")))
}
loadTemplates()
fsc.FrontendConfig.BaseRepoURL = fsc.GitRepoURL
fsc.FrontendConfig.IsPublic = fsc.IsPublicView
frontendConfigBytes, err := json.Marshal(fsc.FrontendConfig)
if err != nil {
sklog.Error("Failed to marshal frontend config to JSON: %s", err)
}
templateHandler := func(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if fsc.ForceLogin && len(plogin.Roles(r)) == 0 {
http.Redirect(w, r, plogin.LoginURL(r), http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html")
// Reload the template if we are running locally.
if fsc.Local {
loadTemplates()
}
templateData := struct {
Title string
GoldSettings template.JS
}{
Title: fsc.FrontendConfig.Title,
GoldSettings: template.JS(frontendConfigBytes),
}
if err := templates.ExecuteTemplate(w, name, templateData); err != nil {
sklog.Errorf("Failed to expand template %s : %s", name, err)
return
}
}
}
// These routes serve the web UI.
router.HandleFunc("/", templateHandler("byblame.html"))
router.HandleFunc("/changelists", templateHandler("changelists.html"))
router.HandleFunc("/cluster", templateHandler("cluster.html"))
router.HandleFunc("/triagelog", templateHandler("triagelog.html"))
router.HandleFunc("/ignores", templateHandler("ignorelist.html"))
router.HandleFunc("/diff", templateHandler("diff.html"))
router.HandleFunc("/detail", templateHandler("details.html"))
router.HandleFunc("/details", templateHandler("details.html"))
router.HandleFunc("/list", templateHandler("by_test_list.html"))
router.HandleFunc("/help", templateHandler("help.html"))
router.HandleFunc("/search", templateHandler("search.html"))
router.HandleFunc("/cl/{system}/{id}", handlers.ChangelistSearchRedirect)
}
// addAuthenticatedJSONRoutes populates the given router with the subset of Gold's JSON RPC routes
// that require authentication.
func addAuthenticatedJSONRoutes(router chi.Router, fsc *frontendServerConfig, handlers *web.Handlers, plogin alogin.Login) {
// Set up a subrouter for the '/json' routes which make up the Gold API.
// This makes routing faster, but also returns a failure when an /json route is
// requested that doesn't exist. If we did this differently a call to a non-existing endpoint
// would be handled by the route that handles the returning the index template and make
// debugging confusing.
pathPrefix := "/json"
jsonRouter := router.Route(pathPrefix, func(r chi.Router) {})
add := func(jsonRoute string, handlerToProtect http.HandlerFunc, method string) {
wrappedHandler := func(w http.ResponseWriter, r *http.Request) {
// Any role is >= Viewer
if fsc.ForceLogin && len(plogin.Roles(r)) == 0 {
http.Error(w, "You must be logged in as a viewer to complete this action.", http.StatusUnauthorized)
return
}
handlerToProtect(w, r)
}
addJSONRoute(method, jsonRoute, wrappedHandler, jsonRouter, pathPrefix)
}
add("/json/v2/byblame", handlers.ByBlameHandler, "GET")
add("/json/v2/changelists", handlers.ChangelistsHandler, "GET")
add("/json/v2/clusterdiff", handlers.ClusterDiffHandler, "GET")
add("/json/v2/commits", handlers.CommitsHandler, "GET")
add("/json/v1/positivedigestsbygrouping/{groupingID}", handlers.PositiveDigestsByGroupingIDHandler, "GET")
add("/json/v2/details", handlers.DetailsHandler, "POST")
add("/json/v2/diff", handlers.DiffHandler, "POST")
add("/json/v2/digests", handlers.DigestListHandler, "GET")
add("/json/v2/latestpositivedigest/{traceID}", handlers.LatestPositiveDigestHandler, "GET")
add("/json/v2/list", handlers.ListTestsHandler, "GET")
add("/json/v2/paramset", handlers.ParamsHandler, "GET")
add("/json/v2/search", handlers.SearchHandler, "GET")
add("/json/v2/triage", handlers.TriageHandlerV2, "POST") // TODO(lovisolo): Delete when unused.
add("/json/v3/triage", handlers.TriageHandlerV3, "POST")
add("/json/v2/triagelog", handlers.TriageLogHandler, "GET")
add("/json/v2/triagelog/undo", handlers.TriageUndoHandler, "POST")
add("/json/whoami", handlers.Whoami, "GET")
add("/json/v1/whoami", handlers.Whoami, "GET")
// Only expose these endpoints if this instance is not a public view. The reason we want to hide
// ignore rules is so that we don't leak params that might be in them.
if !fsc.IsPublicView {
add("/json/v2/ignores", handlers.ListIgnoreRules2, "GET")
add("/json/ignores/add/", handlers.AddIgnoreRule, "POST")
add("/json/v1/ignores/add/", handlers.AddIgnoreRule, "POST")
add("/json/ignores/del/{id}", handlers.DeleteIgnoreRule, "POST")
add("/json/v1/ignores/del/{id}", handlers.DeleteIgnoreRule, "POST")
add("/json/ignores/save/{id}", handlers.UpdateIgnoreRule, "POST")
add("/json/v1/ignores/save/{id}", handlers.UpdateIgnoreRule, "POST")
}
// Make sure we return a 404 for anything that starts with /json and could not be found.
jsonRouter.HandleFunc("/{ignore:.*}", http.NotFound)
router.HandleFunc(pathPrefix, http.NotFound)
}
// addUnauthenticatedJSONRoutes populates the given router with the subset of Gold's JSON RPC routes
// that do not require authentication.
func addUnauthenticatedJSONRoutes(router chi.Router, _ *frontendServerConfig, handlers *web.Handlers) {
add := func(jsonRoute string, handlerFunc http.HandlerFunc) {
addJSONRoute("GET", jsonRoute, httputils.CorsHandler(handlerFunc), router, "")
}
add("/json/v2/trstatus", handlers.StatusHandler)
add("/json/v2/changelist/{system}/{id}", handlers.PatchsetsAndTryjobsForCL2)
add("/json/v1/changelist_summary/{system}/{id}", handlers.ChangelistSummaryHandler)
// Routes shared with the baseline server. These usually don't see traffic because the envoy
// routing directs these requests to the baseline servers, if there are some.
add(frontend.KnownHashesRoute, handlers.KnownHashesHandler)
add(frontend.KnownHashesRouteV1, handlers.KnownHashesHandler)
// Retrieving a baseline for the primary branch and a Gerrit issue are handled the same way.
// These routes can be served with baseline_server for higher availability.
add(frontend.ExpectationsRouteV2, handlers.BaselineHandlerV2)
add(frontend.GroupingsRouteV1, handlers.GroupingsHandler)
}
var (
unversionedJSONRouteRegexp = regexp.MustCompile(`/json/(?P<path>.+)`)
versionedJSONRouteRegexp = regexp.MustCompile(`/json/v(?P<version>\d+)/(?P<path>.+)`)
)
// addJSONRoute adds a handler function to a router for the given JSON RPC route, which must be of
// the form "/json/<path>" or "/json/v<n>/<path>", and increases a counter to track RPC and version
// usage every time the RPC is invoked.
//
// If the given routerPathPrefix is non-empty, it will be removed from the JSON RPC route before the
// handler function is added to the router (useful with subrouters for path prefixes, e.g. "/json").
//
// It panics if jsonRoute does not start with '/json', or if the routerPathPrefix is not a prefix of
// the jsonRoute, or if the jsonRoute uses version 0 (e.g. /json/v0/foo), which is reserved for
// unversioned RPCs.
//
// This function has been designed to take the full JSON RPC route as an argument, including the
// RPC version number and the subrouter path prefix, if any (e.g. "/json/v2/my/rpc" vs. "/my/rpc").
// This results in clearer code at the callsite because the reader can immediately see what the
// final RPC route will look like from outside the HTTP server.
func addJSONRoute(method, jsonRoute string, handlerFunc http.HandlerFunc, router chi.Router, routerPathPrefix string) {
// Make sure the jsonRoute agrees with the router path prefix (which can be the empty string).
if !strings.HasPrefix(jsonRoute, routerPathPrefix) {
panic(fmt.Sprintf(`Prefix "%s" not found in JSON RPC route: %s`, routerPathPrefix, jsonRoute))
}
// Parse the JSON RPC route, which can be of the form "/json/v<n>/<path>" or "/json/<path>", and
// extract <path> and <n>, defaulting to 0 for the unversioned case.
var path string
version := 0 // Default value is used for unversioned JSON RPCs.
if matches := versionedJSONRouteRegexp.FindStringSubmatch(jsonRoute); matches != nil {
var err error
version, err = strconv.Atoi(matches[1])
if err != nil {
// Should never happen.
panic("Failed to convert RPC version to integer (indicates a bug in the regexp): " + jsonRoute)
}
if version == 0 {
// Disallow /json/v0/* because we indicate unversioned RPCs with version 0.
panic("JSON RPC version cannot be 0: " + jsonRoute)
}
path = matches[2]
} else if matches := unversionedJSONRouteRegexp.FindStringSubmatch(jsonRoute); matches != nil {
path = matches[1]
} else {
// The path is neither a versioned nor an unversioned JSON RPC route. This is a coding error.
panic("Unrecognized JSON RPC route format: " + jsonRoute)
}
counter := metrics2.GetCounter(web.RPCCallCounterMetric, map[string]string{
"route": "/" + path,
"version": fmt.Sprintf("v%d", version),
})
pattern := strings.TrimPrefix(jsonRoute, routerPathPrefix)
fn := func(w http.ResponseWriter, r *http.Request) {
counter.Inc(1)
handlerFunc(w, r)
}
switch method {
case "GET":
router.Get(pattern, fn)
case "POST":
router.Post(pattern, fn)
default:
panic(fmt.Sprintf("unknown method: %s", method))
}
}
// makeResourceHandler creates a static file handler that sets a caching policy.
func makeResourceHandler(resourceDir string) func(http.ResponseWriter, *http.Request) {
fileServer := http.FileServer(http.Dir(resourceDir))
return func(w http.ResponseWriter, r *http.Request) {
// No limit for anon users - this should be fast enough to handle a large load.
w.Header().Add("Cache-Control", "max-age=300")
fileServer.ServeHTTP(w, r)
}
}