From c4dbdae5605e3698dee5dcc5b0bd9d78aa9235be Mon Sep 17 00:00:00 2001 From: Peter Sanchez Date: Thu, 8 Aug 2024 14:47:22 -0600 Subject: [PATCH] Adding additional integration items for gobwebs ecosystem --- client.go | 104 ++++++++++++++++++++ go.mod | 7 +- go.sum | 12 +++ graphql.go | 39 ++++---- input.go | 23 +++++ interfaces.go | 13 +++ routes.go | 229 +++++++++++++++++++++++++++++++++++++++++++ valid.go | 265 ++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 670 insertions(+), 22 deletions(-) create mode 100644 client.go create mode 100644 input.go create mode 100644 interfaces.go create mode 100644 routes.go create mode 100644 valid.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..8514f6f --- /dev/null +++ b/client.go @@ -0,0 +1,104 @@ +package gobwebsgql + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "git.sr.ht/~emersion/gqlclient" + "github.com/labstack/echo/v4" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "netlandish.com/x/gobwebs" + "netlandish.com/x/gobwebs/server" + "netlandish.com/x/gobwebs/validate" +) + +type ExecuteFunc func(ctx context.Context, op *gqlclient.Operation, result interface{}) error + +// Execute is the default function to call a graphql query +func Execute(ctx context.Context, op *gqlclient.Operation, result interface{}) error { + var ( + client *gqlclient.Client + httpClient *http.Client + origin string + ) + + // Requires the gobwebs server.Middleware middleware in use + srv := server.ForContext(ctx) + + if ostr, ok := srv.Config.File.Get("graphql", "api-origin"); ok { + origin = ostr + } else { + origin = "http://127.0.0.1:8080/query" + } + + httpClient = &http.Client{ + Timeout: 30 * time.Second, + } + + client = gqlclient.New(origin, httpClient) + err := client.Execute(ctx, op, &result) + return err +} + +type errorExtension struct { + Code int + Field string +} + +type queryErrorExtension struct { + Code string `json:"code"` +} + +type ParseInputErrorsFunc func(c echo.Context, graphError *gqlclient.Error, fMap gobwebs.Map) error + +// ParseInputErrors parse the errors list returned by the graphql api +// and parse them into InputErrors to be displayed in the html form +// if the error code is `ErrNotFoundCode` the handler will return 404 +func ParseInputErrors(c echo.Context, graphError *gqlclient.Error, fMap gobwebs.Map) error { + if len(graphError.Extensions) == 0 { + return graphError + } + + inputErrs := validate.NewInputErrors() + var ext errorExtension + err := json.Unmarshal(graphError.Extensions, &ext) + if err != nil { + // Try for query error + // {"code":"GRAPHQL_VALIDATION_FAILED"} + var qext queryErrorExtension + err = json.Unmarshal(graphError.Extensions, &qext) + if err != nil { + return err + } + inputErrs["_global_"] = []string{fmt.Sprintf("Query error: %s", qext.Code)} + return inputErrs + } + + if ext.Code == ErrNotFoundCode { + return echo.NotFoundHandler(c) + } + + if ext.Code == ErrValidationGlobalCode || ext.Code == ErrRestrictedCode { + inputErrs["_global_"] = []string{graphError.Message} + return inputErrs + } + + if ext.Code == ErrValidationCode { + field, ok := fMap[ext.Field] + if ok { + // We use the mapped name + inputErrs[field.(string)] = graphError.Message + } else { + // We capitalize the first letter to fulfill golang name convention + caser := cases.Title(language.English, cases.NoLower) + inputErrs[caser.String(ext.Field)] = graphError.Message + } + return inputErrs + + } + return graphError +} diff --git a/go.mod b/go.mod index ea0e62e..6960a11 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,12 @@ module netlandish.com/x/gobwebs-graphql go 1.21 require ( + git.sr.ht/~emersion/gqlclient v0.0.0-20230820050442-8873fe0204b9 github.com/99designs/gqlgen v0.17.49 + github.com/alecthomas/chroma/v2 v2.14.0 github.com/labstack/echo/v4 v4.12.0 + github.com/vektah/gqlparser/v2 v2.5.16 + golang.org/x/text v0.16.0 netlandish.com/x/gobwebs v0.0.0-20240628133233-8fea7ee22ba2 petersanchez.com/x/carrier v0.2.1 ) @@ -18,6 +22,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cloudflare/circl v1.3.9 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.0 // indirect github.com/emersion/go-message v0.18.1 // indirect github.com/emersion/go-pgpmail v0.2.1 // indirect @@ -56,12 +61,10 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec // indirect - github.com/vektah/gqlparser/v2 v2.5.16 // indirect golang.org/x/crypto v0.25.0 // indirect golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.57.0 // indirect diff --git a/go.sum b/go.sum index d116128..2792cca 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +git.sr.ht/~emersion/gqlclient v0.0.0-20230820050442-8873fe0204b9 h1:QNwHP6WknvS7X6MEFxCpefQb1QJMqgIIt+vn/PVoMMg= +git.sr.ht/~emersion/gqlclient v0.0.0-20230820050442-8873fe0204b9/go.mod h1:kvl/JK0Z3VRmtbBxdOJR4ydyXVouUIcFIXgv4H6rVAY= git.sr.ht/~sircmpwn/dowork v0.0.0-20221010085743-46c4299d76a1 h1:EvPKkneKkF/f7zEgKPqIZVyj3jWO8zSmsBOvMhAGqMA= git.sr.ht/~sircmpwn/dowork v0.0.0-20221010085743-46c4299d76a1/go.mod h1:8neHEO3503w/rNtttnR0JFpQgM/GFhaafVwvkPsFIDw= github.com/99designs/gqlgen v0.17.49 h1:b3hNGexHd33fBSAd4NDT/c3NCcQzcAVkknhN9ym36YQ= @@ -44,6 +46,12 @@ github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -82,6 +90,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw= @@ -181,6 +191,8 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= diff --git a/graphql.go b/graphql.go index be43152..d8d09f7 100644 --- a/graphql.go +++ b/graphql.go @@ -35,6 +35,7 @@ type GraphQL struct { type GQLExtension struct { GQL *GraphQL s *server.Server + eg *echo.Group } func (g *GQLExtension) Extend(gserver *server.Server) (*server.Server, error) { @@ -80,35 +81,32 @@ func (g *GQLExtension) Extend(gserver *server.Server) (*server.Server, error) { srv.SetRecoverFunc(g.gqlEmailRecover) srv.Use(extension.FixedComplexityLimit(g.GQL.MaxComplexity)) - if g.s.Config.Debug { - g.s.Echo().GET("/graphql", func(c echo.Context) error { - // Gross - ctx := server.EchoContext(c.Request().Context(), c) - c.SetRequest(c.Request().WithContext(ctx)) - phand := playground.Handler("GraphQL playground", "/query") - phand.ServeHTTP(c.Response(), c.Request()) - return nil - }) - } - g.s.Echo().POST("/query", func(c echo.Context) error { - // More gross + qroute := g.eg.POST("/query", func(c echo.Context) error { + // Hate this... ctx := server.EchoContext(c.Request().Context(), c) c.SetRequest(c.Request().WithContext(ctx)) srv.ServeHTTP(c.Response(), c.Request()) return nil }) - g.s.Echo().GET("/query/api-scopes.json", func(c echo.Context) error { + g.eg.GET("/query/api-scopes.json", func(c echo.Context) error { info := struct { Scopes []string `json:"scopes"` }{g.GQL.Scopes} - //j, err := json.Marshal(&info) - //if err != nil { - // panic(err) - //} - return c.JSON(http.StatusOK, &info) }) + if g.s.Config.Debug { + // If debug is set, add a handler for the default playground + g.eg.GET("/graphql-debug", func(c echo.Context) error { + // Hate this... + ctx := server.EchoContext(c.Request().Context(), c) + c.SetRequest(c.Request().WithContext(ctx)) + phand := playground.Handler("GraphQL playground", qroute.Path) + phand.ServeHTTP(c.Response(), c.Request()) + return nil + }) + } + return g.s, nil } @@ -210,7 +208,8 @@ The following stack trace was produced: return fmt.Errorf("internal system error") } -func New(schema graphql.ExecutableSchema, scopes []string) *GQLExtension { +// NewGQLExtension will return a GQLExtension that can be passed to a gobwebs server +func NewGQLExtension(eg *echo.Group, schema graphql.ExecutableSchema, scopes []string) *GQLExtension { gql := &GraphQL{Schema: schema, Scopes: scopes} - return &GQLExtension{GQL: gql} + return &GQLExtension{GQL: gql, eg: eg} } diff --git a/input.go b/input.go new file mode 100644 index 0000000..f98b33e --- /dev/null +++ b/input.go @@ -0,0 +1,23 @@ +package gobwebsgql + +import ( + "github.com/labstack/echo/v4" + "netlandish.com/x/gobwebs/validate" +) + +// PlaygroundForm ... +type PlaygroundForm struct { + Query string `form:"query" validate:"required"` +} + +// Validate ... +func (p *PlaygroundForm) Validate(c echo.Context) error { + errs := validate.FormFieldBinder(c, p). + FailFast(false). + String("query", &p.Query). + BindErrors() + if errs != nil { + return validate.GetInputErrors(errs) + } + return c.Validate(p) +} diff --git a/interfaces.go b/interfaces.go new file mode 100644 index 0000000..86ae028 --- /dev/null +++ b/interfaces.go @@ -0,0 +1,13 @@ +package gobwebsgql + +import ( + "github.com/labstack/echo/v4" + "netlandish.com/x/gobwebs" +) + +// Helper is an interface to allow you to customize behavior +// after a specific action from the application routes +type Helper interface { + // Personal access and authorized client tokens + GQLPlaygroundTemplateVars(c echo.Context) gobwebs.Map +} diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..70cbcab --- /dev/null +++ b/routes.go @@ -0,0 +1,229 @@ +package gobwebsgql + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "html/template" + "net/http" + + "git.sr.ht/~emersion/gqlclient" + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/labstack/echo/v4" + "netlandish.com/x/gobwebs" + "netlandish.com/x/gobwebs/auth" + "netlandish.com/x/gobwebs/core" + "netlandish.com/x/gobwebs/server" + "netlandish.com/x/gobwebs/validate" +) + +// Used by the graphql playground +var cssStyle, schemaHTML template.HTML + +// ServiceConfig let's you add basic config variables to service +type ServiceConfig struct { + // Schema is the graphql schema, displayed on the featured playground + Schema string + + // Query is the default query ran on the featured playground + Query string + + // Helper is the helper integration for external apps + Helper Helper + + // AuthRequired is a bool to determine whether or not the user is required to be + // authenticated in order to use the GraphQL playground. Remember to speicifcally set + // this to `true` if you want it to be required. + AuthRequired bool + + // RenderFunc can be used to customize the template renderer + RenderFunc validate.TemplateRenderFunc + + // ExecuteFunc will be used for the graphql query calls + ExecuteFunc ExecuteFunc + + // ParseInputErrorsFunc will be used to process graphql errors for use in html forms. + // This is called from the GraphQL playground + ParseInputErrorsFunc ParseInputErrorsFunc +} + +// Service is the GraphQL helper handlers struct +type Service struct { + name string + eg *echo.Group + + config *ServiceConfig + render validate.TemplateRenderFunc + execute ExecuteFunc + parse ParseInputErrorsFunc +} + +// RegisterRoutes ... +func (s *Service) RegisterRoutes() { + if s.config.AuthRequired { + s.eg.Use(auth.AuthRequired()) + } + s.eg.GET("/graphql", s.GQLPlayground).Name = s.RouteName("gql_playground") + s.eg.POST("/graphql", s.GQLPlayground).Name = s.RouteName("gql_playground_post") +} + +func (s *Service) GQLPlayground(c echo.Context) error { + result := make(map[string]any) + + lt := core.GetSessionLocalizer(c) + pd := core.NewPageData(lt.Translate("GraphQL Playground")) + pd.Data["submit_query"] = lt.Translate("Submit Query") + pd.Data["back_home"] = lt.Translate("Back Home") + + form := &PlaygroundForm{} + gmap := s.config.Helper.GQLPlaygroundTemplateVars(c) + gmap["query"] = s.config.Query + + formatter := html.New(html.WithClasses(true)) + style := styles.Get("monokailight") + + if cssStyle == "" { + var cssBuf bytes.Buffer + cssw := bufio.NewWriter(&cssBuf) + if err := formatter.WriteCSS(cssw, style); err != nil { + return fmt.Errorf("Error generating schema HTML: %w", err) + } + + cssw.Flush() + cssStyle = template.HTML(fmt.Sprintf("", cssBuf.String())) + } + gmap["style"] = cssStyle + + if schemaHTML == "" && s.config.Schema != "" { + lexer := lexers.Get("graphql") + var schemaBuf bytes.Buffer + + iterator, err := lexer.Tokenise(nil, s.config.Schema) + if err != nil { + return fmt.Errorf("Error tokenizing schema: %w", err) + } + + schw := bufio.NewWriter(&schemaBuf) + err = formatter.Format(schw, style, iterator) + if err != nil { + return fmt.Errorf("Error formatting schema: %w", err) + } + + schw.Flush() + schemaHTML = template.HTML(schemaBuf.String()) + } + gmap["schema"] = schemaHTML + + req := c.Request() + if req.Method == http.MethodPost { + if err := form.Validate(c); err != nil { + switch err.(type) { + case validate.InputErrors: + gmap["errors"] = err + gmap["form"] = form + return s.Render(c, http.StatusOK, "graphql.html", gmap) + default: + return err + } + } + + gmap["query"] = form.Query + op := gqlclient.NewOperation(form.Query) + err := s.execute(c.Request().Context(), op, &result) + if err != nil { + if graphError, ok := err.(*gqlclient.Error); ok { + gmap["form"] = form + err = s.parse(c, graphError, gobwebs.Map{}) + switch err.(type) { + case validate.InputErrors: + gmap["errors"] = err + default: + inputErrs := validate.NewInputErrors() + inputErrs["_global_"] = []string{graphError.Message} + gmap["errors"] = inputErrs + } + return s.Render(c, http.StatusOK, "graphql.html", gmap) + } + return err + } + } else { + form.Query = s.config.Query + if s.config.Query != "" { + op := gqlclient.NewOperation(s.config.Query) + err := s.execute(c.Request().Context(), op, &result) + if err != nil { + return err + } + } + gmap["form"] = form + } + + // Pretty dumb to re-parse this but better than adjusting the existing + // gql request code + res, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + + var resBuf bytes.Buffer + resw := bufio.NewWriter(&resBuf) + lexer := lexers.Get("json") + iterator, err := lexer.Tokenise(nil, string(res)) + if err != nil { + return fmt.Errorf("Error tokenizing json response: %w", err) + } + + err = formatter.Format(resw, style, iterator) + if err != nil { + return fmt.Errorf("Error formatting json response: %w", err) + } + + resw.Flush() + gmap["results"] = template.HTML(resBuf.String()) + return s.Render(c, http.StatusOK, "graphql.html", gmap) +} + +func (s *Service) Render(c echo.Context, code int, name string, data interface{}) error { + if s.render != nil { + return s.render(c, code, name, data) + } + gctx := c.(*server.Context) + return gctx.Render(code, name, data) +} + +// RouteName ... +func (s *Service) RouteName(value string) string { + return fmt.Sprintf("%s:%s", s.name, value) +} + +// NewService return service +func NewService(eg *echo.Group, config *ServiceConfig) *Service { + if config.Helper == nil { + panic(fmt.Errorf("No gobwebsgql Helper interface provided via ServiceConfig")) + } + + service := &Service{ + name: "graphql", + eg: eg, + config: config, + execute: Execute, + parse: ParseInputErrors, + } + if config != nil { + if config.RenderFunc != nil { + service.render = config.RenderFunc + } + if config.ExecuteFunc != nil { + service.execute = config.ExecuteFunc + } + if config.ParseInputErrorsFunc != nil { + service.parse = config.ParseInputErrorsFunc + } + } + + service.RegisterRoutes() + return service +} diff --git a/valid.go b/valid.go new file mode 100644 index 0000000..168bb37 --- /dev/null +++ b/valid.go @@ -0,0 +1,265 @@ +package gobwebsgql + +// This file and concept is taken from https://git.sr.ht/~sircmpwn/core-go. It's +// modified to fit the needs of a gobwebs project but the license for this file +// is listed below. + +//Copyright 2020 Drew DeVault + +//Redistribution and use in source and binary forms, with or without +//modification, are permitted provided that the following conditions are met: + +//1. Redistributions of source code must retain the above copyright notice, this +//list of conditions and the following disclaimer. + +//2. Redistributions in binary form must reproduce the above copyright notice, +//this list of conditions and the following disclaimer in the documentation +//and/or other materials provided with the distribution. + +//3. Neither the name of the copyright holder nor the names of its contributors +//may be used to endorse or promote products derived from this software without +//specific prior written permission. + +//THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +//ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +//WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +//DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +//FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +//DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +//SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +//CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +//OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +//OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import ( + "context" + "errors" + "fmt" + + "github.com/99designs/gqlgen/graphql" + "github.com/vektah/gqlparser/v2/gqlerror" +) + +// Standard codes used in gobwebs projects +const ( + ErrValidationCode int = 100 + ErrNotFoundCode int = 200 + ErrValidationGlobalCode int = 300 + ErrRestrictedCode int = 400 +) + +var ( + // ErrAuthorization ... + ErrAuthorization = errors.New("Authorizatoin required") +) + +// Validation ... +type Validation struct { + ctx context.Context + input map[string]interface{} +} + +// ValidationError ... +type ValidationError struct { + valid *Validation + err *gqlerror.Error +} + +// Error Returns a new GraphQL error attached to the given field. +func Error(ctx context.Context, field string, msg string) error { + return &gqlerror.Error{ + Message: msg, + Path: graphql.GetPath(ctx), + Extensions: map[string]interface{}{ + "field": field, + }, + } +} + +// Errorf Returns a new GraphQL error attached to the given field. +func Errorf(ctx context.Context, field string, msg string, items ...interface{}) error { + return &gqlerror.Error{ + Message: fmt.Sprintf(msg, items...), + Path: graphql.GetPath(ctx), + Extensions: map[string]interface{}{ + "field": field, + }, + } +} + +// New Creates a new validation context. +func New(ctx context.Context) *Validation { + return &Validation{ + ctx: ctx, + } +} + +// WithInput Adds an input map to a validation context. +func (valid *Validation) WithInput(input map[string]interface{}) *Validation { + valid.input = input + return valid +} + +// Ok Returns true if no errors were found. +func (valid *Validation) Ok() bool { + return len(graphql.GetErrors(valid.ctx)) == 0 +} + +// Optional Fetches an item from the validation context, which must have an input +// registered. If the field is not present, the callback is not run. Otherwise, +// the function is called with the value for the user to conduct further +// validation with. +func (valid *Validation) Optional(name string, fn func(i interface{})) { + if valid.input == nil { + panic("Attempted to validate fields without input") + } + if o, ok := valid.input[name]; ok { + if o == nil { + return + } + fn(o) + } +} + +// OptionalString Fetches a string from the validation context, which must have an input +// registered. If the field is not present, the callback is not run. If +// present, but not a string, an error is recorded. Otherwise, the function is +// called with the string for the user to conduct further validation with. +func (valid *Validation) OptionalString(name string, fn func(s string)) { + if valid.input == nil { + panic("Attempted to validate fields without input") + } + if o, ok := valid.input[name]; ok { + if o == nil { + return + } + var val string + switch s := o.(type) { + case string: + val = s + case *string: + val = *s + default: + valid. + Error("Expected %s to be a string", name). + WithField(name) + return + } + fn(val) + } +} + +// NullableString Fetches a nullable string from the validation context, which must have an +// input registered. If the field is not present, the callback is not run. If +// present, but null, the function is called with null set to true. Otherwise, +// the function is called with the string for the user to conduct further +// validation with. +func (valid *Validation) NullableString(name string, fn func(s *string)) { + if valid.input == nil { + panic("Attempted to validate fields without input") + } + if o, ok := valid.input[name]; ok { + var val *string + if o != nil { + switch s := o.(type) { + case string: + val = &s + case *string: + val = s + default: + valid. + Error("Expected %s to be a string", name). + WithField(name) + return + } + } + fn(val) + } +} + +// OptionalBool Fetches a boolean from the validation context, which must have an input +// registered. If the field is not present, the callback is not run. If +// present, but not a boolean, an error is recorded. Otherwise, the function is +// called with the boolean for the user to conduct further validation with. +func (valid *Validation) OptionalBool(name string, fn func(b bool)) { + if valid.input == nil { + panic("Attempted to validate fields without input") + } + if o, ok := valid.input[name]; ok { + if o == nil { + return + } + var val bool + switch b := o.(type) { + case bool: + val = b + case *bool: + val = *b + default: + valid. + Error("Expected %s to be a bool", name). + WithField(name) + return + } + fn(val) + } +} + +// Creates a validation error unconditionally. +func (valid *Validation) Error(msg string, + items ...interface{}) *ValidationError { + err := &gqlerror.Error{ + Path: graphql.GetPath(valid.ctx), + Message: fmt.Sprintf(msg, items...), + } + graphql.AddError(valid.ctx, err) + return &ValidationError{ + valid: valid, + err: err, + } +} + +// Expect Asserts that a condition is true, recording a GraphQL error with the given +// message if not. +func (valid *Validation) Expect(cond bool, + msg string, items ...interface{}) *ValidationError { + if cond { + return &ValidationError{valid: valid} + } + return valid.Error(msg, items...) +} + +// WithField Associates a field name with an error. +func (err *ValidationError) WithField(field string) *ValidationError { + if err.err == nil { + return err + } + if err.err.Extensions == nil { + err.err.Extensions = make(map[string]interface{}) + } + err.err.Extensions["field"] = field + return err +} + +// WithCode Associates an error message with an error code. +func (err *ValidationError) WithCode(code int) *ValidationError { + if err.err == nil { + return err + } + if err.err.Extensions == nil { + err.err.Extensions = make(map[string]interface{}) + } + err.err.Extensions["code"] = code + return err +} + +// And Composes another assertion onto the same validation context which initially +// created an error. Short-circuiting is used, such that if the earlier +// condition failed, the new condition is not considered. +func (err *ValidationError) And(cond bool, + msg string, items ...interface{}) *ValidationError { + if err.err != nil { + return err + } + return err.valid.Expect(cond, msg, items...) +} -- 2.45.2