diff --git a/go.mod b/go.mod index 022ed9242..fa2a5f36e 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.23.4 require ( gearno.de/ref v0.0.0-20221013162104-a522beda40f4 github.com/99designs/gqlgen v0.17.63 + github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/cors v1.2.1 github.com/jackc/pgx/v5 v5.7.1 github.com/prometheus/client_golang v1.20.5 github.com/vektah/gqlparser/v2 v2.5.21 @@ -19,11 +21,13 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect - github.com/go-chi/chi/v5 v5.1.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/go.sum b/go.sum index 73db4c9d4..d30df35a5 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,14 @@ gearno.de/ref v0.0.0-20221013162104-a522beda40f4 h1:tS3oPI89+Y33vZRR/DwbNTewefyc gearno.de/ref v0.0.0-20221013162104-a522beda40f4/go.mod h1:yMxgb+Im8XTCLBBgpy8p5xE6/OaWkKyfypNd3HPQpLw= github.com/99designs/gqlgen v0.17.63 h1:HCdaYDPd9HqUXRchEvmE3EFzELRwLlaJ8DBuyC8Cqto= github.com/99designs/gqlgen v0.17.63/go.mod h1:sVCM2iwIZisJjTI/DEC3fpH+HFgxY1496ZJ+jbT9IjA= +github.com/PuerkitoBio/goquery v1.9.3 h1:mpJr/ikUA9/GNJB/DBZcGeFDXUtosHRyRrwh7KGdTG0= +github.com/PuerkitoBio/goquery v1.9.3/go.mod h1:1ndLHPdTz+DyQPICCWYlYQMPl0oXZj0G6D4LCYA6u4U= github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY= github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -23,6 +27,8 @@ github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7c github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/pkg/api/api.go b/pkg/api/api.go new file mode 100644 index 000000000..016ff613f --- /dev/null +++ b/pkg/api/api.go @@ -0,0 +1,98 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package api + +import ( + "errors" + "net/http" + + console_v1 "github.com/getprobo/probo/pkg/api/console/v1" + "github.com/getprobo/probo/pkg/probo" + "github.com/go-chi/chi/v5" + "github.com/go-chi/cors" + "go.gearno.de/kit/httpserver" +) + +type ( + Config struct { + AllowedOrigins []string + Probo *probo.Service + } + + Server struct { + cfg Config + } +) + +var ( + ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance") +) + +func methodNotAllowed(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + httpserver.RenderJSON( + w, + http.StatusMethodNotAllowed, + map[string]string{ + "error": "method not allowed", + }, + ) +} + +func notFound(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + httpserver.RenderJSON( + w, + http.StatusNotFound, + map[string]string{ + "error": "not found", + }, + ) +} + +func NewServer(cfg Config) (*Server, error) { + if cfg.Probo == nil { + return nil, ErrMissingProboService + } + + return &Server{ + cfg: cfg, + }, nil +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + corsOpts := cors.Options{ + AllowedOrigins: s.cfg.AllowedOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"}, + AllowedHeaders: []string{"content-type", "traceparent"}, + ExposedHeaders: []string{"x-Request-id"}, + AllowCredentials: true, + MaxAge: 600, // 10 minutes (chrome >= 76 maximum value c.f. https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/cors/preflight_result.cc;drc=52002151773d8cd9ffc5f557cd7cc880fddcae3e;l=36) + OptionsPassthrough: false, + Debug: false, + } + + router := chi.NewRouter() + router.MethodNotAllowed(methodNotAllowed) + router.NotFound(notFound) + + router.Use(cors.Handler(corsOpts)) + + router.Mount("/console/v1", console_v1.NewMux()) + + router.ServeHTTP(w, r) +} diff --git a/pkg/api/console/v1/resolver.go b/pkg/api/console/v1/resolver.go index eeb9ff850..3c9599b13 100644 --- a/pkg/api/console/v1/resolver.go +++ b/pkg/api/console/v1/resolver.go @@ -16,10 +16,46 @@ package console_v1 -import "github.com/getprobo/probo/pkg/probo" +import ( + "net/http" + + "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/handler/extension" + "github.com/99designs/gqlgen/graphql/handler/transport" + "github.com/99designs/gqlgen/graphql/playground" + "github.com/getprobo/probo/pkg/api/console/v1/schema" + "github.com/getprobo/probo/pkg/probo" + "github.com/go-chi/chi/v5" +) type ( Resolver struct { svc *probo.Service } ) + +func NewMux() *chi.Mux { + r := chi.NewMux() + r.Get("/", playground.Handler("GraphQL", "/console/v1/query")) + r.Post("/query", graphql()) + + return r +} + +func graphql() http.HandlerFunc { + es := schema.NewExecutableSchema( + schema.Config{ + Resolvers: &Resolver{}, + }, + ) + srv := handler.New(es) + srv.AddTransport(transport.Options{}) + srv.AddTransport(transport.GET{}) + srv.AddTransport(transport.POST{}) + srv.AddTransport(transport.MultipartForm{}) + srv.Use(extension.Introspection{}) + + return func(w http.ResponseWriter, r *http.Request) { + srv.ServeHTTP(w, r) + } +} diff --git a/pkg/probod/api_config.go b/pkg/probod/api_config.go index e6db97891..56bef0787 100644 --- a/pkg/probod/api_config.go +++ b/pkg/probod/api_config.go @@ -15,7 +15,12 @@ package probod type ( + corsConfig struct { + AllowedOrigins []string `json:"allowed-origins"` + } + apiConfig struct { - Addr string `json:"addr"` + Addr string `json:"addr"` + Cors corsConfig `json:"cors"` } ) diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 6e9856c3d..cfa0dce87 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -23,6 +23,8 @@ import ( "sync" "time" + "github.com/getprobo/probo/pkg/api" + "github.com/getprobo/probo/pkg/probo" "github.com/prometheus/client_golang/prometheus" "go.gearno.de/kit/httpserver" "go.gearno.de/kit/log" @@ -52,6 +54,9 @@ func New() *Implm { cfg: config{ Api: apiConfig{ Addr: "localhost:8080", + Cors: corsConfig{ + AllowedOrigins: []string{"http://localhost:3000"}, + }, }, Pg: pgConfig{ Addr: "localhost:5432", @@ -78,7 +83,7 @@ func (impl *Implm) Run( ctx, cancel := context.WithCancelCause(parentCtx) defer cancel(context.Canceled) - _, err := pg.NewClient( + pgClient, err := pg.NewClient( impl.cfg.Pg.Options( pg.WithLogger(l), pg.WithRegisterer(r), @@ -89,12 +94,24 @@ func (impl *Implm) Run( return fmt.Errorf("cannot create pg client: %w", err) } + probo := probo.NewService(ctx, pgClient) + + apiServer, err := api.NewServer( + api.Config{ + Probo: probo, + AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins, + }, + ) + if err != nil { + return fmt.Errorf("cannot create api server: %w", err) + } + apiServerCtx, stopApiServer := context.WithCancel(context.Background()) defer stopApiServer() wg.Add(1) go func() { defer wg.Done() - if err := impl.runApiServer(apiServerCtx, l, r, tp, nil); err != nil { + if err := impl.runApiServer(apiServerCtx, l, r, tp, apiServer); err != nil { cancel(fmt.Errorf("api server crashed: %w", err)) } }()