~netlandish/gobwebs-graphql

c4dbdae5605e3698dee5dcc5b0bd9d78aa9235be — Peter Sanchez 4 months ago 33063a0
Adding additional integration items for gobwebs ecosystem
8 files changed, 670 insertions(+), 22 deletions(-)

A client.go
M go.mod
M go.sum
M graphql.go
A input.go
A interfaces.go
A routes.go
A valid.go
A client.go => client.go +104 -0
@@ 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
}

M go.mod => go.mod +5 -2
@@ 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

M go.sum => go.sum +12 -0
@@ 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=

M graphql.go => graphql.go +19 -20
@@ 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}
}

A input.go => input.go +23 -0
@@ 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)
}

A interfaces.go => interfaces.go +13 -0
@@ 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
}

A routes.go => routes.go +229 -0
@@ 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("<style>%s</style>", 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
}

A valid.go => valid.go +265 -0
@@ 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 <sir@cmpwn.com>

//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...)
}