Add template
Signed-off-by: AKP <tom@tdpain.net>
This commit is contained in:
commit
14c167d564
12 changed files with 487 additions and 0 deletions
21
application/config/config.go
Normal file
21
application/config/config.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
)
|
||||
|
||||
func InitLogging() {
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
||||
}
|
||||
|
||||
var HTTP = struct {
|
||||
Host string
|
||||
Port int
|
||||
Secure bool
|
||||
}{
|
||||
Host: asString(withDefault(fetchFromFile("http.host"), "0.0.0.0")),
|
||||
Port: asInt(withDefault(fetchFromFile("http.port"), 8080)),
|
||||
Secure: asBool(required(fetchFromFile("http.secure"))),
|
||||
}
|
96
application/config/file.go
Normal file
96
application/config/file.go
Normal file
|
@ -0,0 +1,96 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
"io/ioutil"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const configFileName = "config.yml"
|
||||
|
||||
var (
|
||||
rawConfigFileContents map[string]any
|
||||
lastKey string
|
||||
)
|
||||
|
||||
func loadConfigFileFromDisk() {
|
||||
if rawConfigFileContents != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fcont, err := ioutil.ReadFile(configFileName)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msgf("failed to load file %s", configFileName)
|
||||
}
|
||||
rawConfigFileContents = make(map[string]any)
|
||||
if err := yaml.Unmarshal(fcont, &rawConfigFileContents); err != nil {
|
||||
log.Fatal().Err(err).Msg("could not unmarshal config file")
|
||||
}
|
||||
}
|
||||
|
||||
type optionalItem struct {
|
||||
item any
|
||||
found bool
|
||||
}
|
||||
|
||||
var indexedPartRegexp = regexp.MustCompile(`(?m)([a-zA-Z]+)(?:\[(\d+)\])?`)
|
||||
|
||||
func fetchFromFile(key string) optionalItem {
|
||||
// http[2].bananas
|
||||
loadConfigFileFromDisk()
|
||||
lastKey = key
|
||||
|
||||
parts := strings.Split(key, ".")
|
||||
var cursor any = rawConfigFileContents
|
||||
for _, part := range parts {
|
||||
components := indexedPartRegexp.FindStringSubmatch(part)
|
||||
key := components[1]
|
||||
index, _ := strconv.ParseInt(components[2], 10, 32)
|
||||
isIndexed := components[2] != ""
|
||||
|
||||
item, found := cursor.(map[string]any)[key]
|
||||
if !found {
|
||||
return optionalItem{nil, false}
|
||||
}
|
||||
|
||||
if isIndexed {
|
||||
arr, conversionOk := item.([]any)
|
||||
if !conversionOk {
|
||||
log.Fatal().Msgf("attempted to index non-indexable item %s", key)
|
||||
}
|
||||
cursor = arr[index]
|
||||
} else {
|
||||
cursor = item
|
||||
}
|
||||
}
|
||||
return optionalItem{cursor, true}
|
||||
}
|
||||
|
||||
func required(opt optionalItem) any {
|
||||
if !opt.found {
|
||||
log.Fatal().Msgf("required key %s not found", lastKey)
|
||||
}
|
||||
return opt.item
|
||||
}
|
||||
|
||||
func withDefault(opt optionalItem, defaultValue any) any {
|
||||
if !opt.found {
|
||||
return defaultValue
|
||||
}
|
||||
return opt.item
|
||||
}
|
||||
|
||||
func asInt(x any) int {
|
||||
return x.(int)
|
||||
}
|
||||
|
||||
func asString(x any) string {
|
||||
return x.(string)
|
||||
}
|
||||
|
||||
func asBool(x any) bool {
|
||||
return x.(bool)
|
||||
}
|
71
application/db/db.go
Normal file
71
application/db/db.go
Normal file
|
@ -0,0 +1,71 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"math"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
pool *sql.DB
|
||||
ContextTimeout time.Duration
|
||||
}
|
||||
|
||||
const maxConnectionAttempts = 4
|
||||
|
||||
func New() (*DB, error) {
|
||||
// TODO: Setup DSN and database driver
|
||||
dsn := ""
|
||||
log.Info().Msg("connecting to database")
|
||||
db, err := sql.Open("postgres", dsn) // TODO: This
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not open SQL connection")
|
||||
}
|
||||
|
||||
rtn := &DB{
|
||||
pool: db,
|
||||
ContextTimeout: time.Second,
|
||||
}
|
||||
|
||||
for i := 1; i <= maxConnectionAttempts; i += 1 {
|
||||
logger := log.With().Int("attempt", i).Int("maxAttempts", maxConnectionAttempts).Logger()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
err := rtn.pool.PingContext(ctx)
|
||||
|
||||
if err == nil {
|
||||
cancel()
|
||||
break
|
||||
}
|
||||
|
||||
if e, ok := err.(*net.OpError); ((ok && e.Op == "dial") || errors.Is(err, context.DeadlineExceeded)) && i != maxConnectionAttempts {
|
||||
cancel()
|
||||
|
||||
retryIn := int(math.Pow(math.E, float64(i)))
|
||||
logger.Warn().Err(err).Msgf("could not connect to database - retrying in %d seconds", retryIn)
|
||||
time.Sleep(time.Second * time.Duration(retryIn))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
cancel()
|
||||
return nil, errors.Wrapf(err, "could not ping database after %d attempts", i)
|
||||
}
|
||||
|
||||
return rtn, nil
|
||||
}
|
||||
|
||||
func (db *DB) newContext() (context.Context, func()) {
|
||||
return context.WithTimeout(context.Background(), db.ContextTimeout)
|
||||
}
|
||||
|
||||
func smartRollback(tx *sql.Tx) {
|
||||
err := tx.Rollback()
|
||||
if err != nil && !errors.Is(err, sql.ErrTxDone) {
|
||||
log.Warn().Stack().Err(errors.WithStack(err)).Str("location", "smartRollback").Msg("failed to rollback transaction")
|
||||
}
|
||||
}
|
80
application/db/migration.go
Normal file
80
application/db/migration.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var migrationFunctions = []func(trans *sql.Tx) error{
|
||||
migrate0to1,
|
||||
}
|
||||
|
||||
func (db *DB) Migrate() error {
|
||||
log.Info().Msg("running migrations")
|
||||
|
||||
// list tables
|
||||
tx, err := db.pool.Begin()
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "could not begin transaction")
|
||||
}
|
||||
defer smartRollback(tx)
|
||||
|
||||
rows, err := db.pool.Query(`SELECT "table_name" FROM "information_schema"."tables" WHERE "table_schema" = 'public';`)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
existingTables := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var tableName string
|
||||
if err := rows.Scan(&tableName); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
existingTables[tableName] = struct{}{}
|
||||
}
|
||||
|
||||
var databaseVersion int
|
||||
|
||||
if _, found := existingTables["version"]; found {
|
||||
err := db.pool.QueryRow(`SELECT "version" FROM "version";`).Scan(&databaseVersion)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
if x := len(migrationFunctions); databaseVersion == x {
|
||||
log.Info().Msg("migrations up-to-date without any changes")
|
||||
return nil
|
||||
} else if databaseVersion > x {
|
||||
return errors.New("corrupt database: database version too high")
|
||||
}
|
||||
|
||||
for _, f := range migrationFunctions[databaseVersion:] {
|
||||
if err := f(tx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msg("committing migrations")
|
||||
return errors.WithStack(
|
||||
tx.Commit(),
|
||||
)
|
||||
}
|
||||
|
||||
//go:embed migrations/0to1.sql
|
||||
var migrate0to1SQL string
|
||||
|
||||
func migrate0to1(trans *sql.Tx) error {
|
||||
log.Info().Msg("migrating new database to v1")
|
||||
|
||||
_, err := trans.Exec(migrate0to1SQL)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to migrate database version from v0 to v1")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
9
application/db/migrations/0to1.sql
Normal file
9
application/db/migrations/0to1.sql
Normal file
|
@ -0,0 +1,9 @@
|
|||
-- Migrate a new database to v1 format.
|
||||
|
||||
CREATE TABLE "version"
|
||||
(
|
||||
version INT
|
||||
);
|
||||
|
||||
INSERT INTO "version"(version)
|
||||
VALUES (1);
|
28
application/endpoints/endpoints.go
Normal file
28
application/endpoints/endpoints.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
package endpoints
|
||||
|
||||
import (
|
||||
"github.com/codemicro/go-fiber-sql/application/db"
|
||||
"github.com/codemicro/go-fiber-sql/application/urls"
|
||||
"github.com/codemicro/go-fiber-sql/application/util"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type Endpoints struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func New(dbi *db.DB) *Endpoints {
|
||||
return &Endpoints{
|
||||
db: dbi,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Endpoints) SetupApp() *fiber.App {
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: util.JSONErrorHandler,
|
||||
})
|
||||
|
||||
app.Get(urls.Index, e.Index)
|
||||
|
||||
return app
|
||||
}
|
7
application/endpoints/index.go
Normal file
7
application/endpoints/index.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package endpoints
|
||||
|
||||
import "github.com/gofiber/fiber/v2"
|
||||
|
||||
func (e *Endpoints) Index(ctx *fiber.Ctx) error {
|
||||
return ctx.SendString("Hello world!")
|
||||
}
|
43
application/main.go
Normal file
43
application/main.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/codemicro/go-fiber-sql/application/config"
|
||||
"github.com/codemicro/go-fiber-sql/application/db"
|
||||
"github.com/codemicro/go-fiber-sql/application/endpoints"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func run() error {
|
||||
database, err := db.New()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := database.Migrate(); err != nil {
|
||||
return errors.Wrap(err, "failed migration")
|
||||
}
|
||||
|
||||
e := endpoints.New(nil)
|
||||
app := e.SetupApp()
|
||||
|
||||
serveAddr := config.HTTP.Host + ":" + strconv.Itoa(config.HTTP.Port)
|
||||
|
||||
log.Info().Msgf("starting server on %s", serveAddr)
|
||||
|
||||
if err := app.Listen(serveAddr); err != nil {
|
||||
return errors.Wrap(err, "fiber server run failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
config.InitLogging()
|
||||
if err := run(); err != nil {
|
||||
fmt.Printf("%+v\n", err)
|
||||
log.Error().Stack().Err(err).Msg("failed to run coordinator")
|
||||
}
|
||||
}
|
5
application/urls/urls.go
Normal file
5
application/urls/urls.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package urls
|
||||
|
||||
const (
|
||||
Index = "/"
|
||||
)
|
63
application/util/richError.go
Normal file
63
application/util/richError.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type RichError struct {
|
||||
Status int
|
||||
Reason string
|
||||
Detail any
|
||||
}
|
||||
|
||||
func NewRichError(status int, reason string, detail any) error {
|
||||
return &RichError{
|
||||
Status: status,
|
||||
Reason: reason,
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRichErrorFromFiberError(err *fiber.Error, detail any) error {
|
||||
return NewRichError(err.Code, err.Message, detail)
|
||||
}
|
||||
|
||||
func (r *RichError) Error() string {
|
||||
return fmt.Sprintf("handler error, %d: %s", r.Status, r.Reason)
|
||||
}
|
||||
|
||||
func (r *RichError) AsJSON() ([]byte, error) {
|
||||
info := map[string]any{
|
||||
"status": "error",
|
||||
"message": r.Reason,
|
||||
}
|
||||
|
||||
if r.Detail != nil {
|
||||
info["detail"] = r.Detail
|
||||
}
|
||||
|
||||
return json.Marshal(info)
|
||||
}
|
||||
|
||||
func JSONErrorHandler(ctx *fiber.Ctx, err error) error {
|
||||
var re *RichError
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
re = NewRichErrorFromFiberError(e, nil).(*RichError)
|
||||
} else if e, ok := err.(*RichError); ok {
|
||||
re = e
|
||||
} else {
|
||||
log.Error().Stack().Err(err).Str("location", "fiber error handler").Str("route", ctx.OriginalURL()).Send()
|
||||
re = NewRichErrorFromFiberError(fiber.ErrInternalServerError, nil).(*RichError)
|
||||
}
|
||||
jsonBytes, err := re.AsJSON()
|
||||
if err != nil {
|
||||
jsonBytes = []byte(`{"status":"error","message":"Internal Server Error","detail":"unable to produce detailed description"}`)
|
||||
log.Error().Err(err).Str("location", "fiber error handler").Msg("unable to produce error response")
|
||||
}
|
||||
ctx.Status(re.Status)
|
||||
ctx.Type("json")
|
||||
return ctx.Send(jsonBytes)
|
||||
}
|
21
go.mod
Normal file
21
go.mod
Normal file
|
@ -0,0 +1,21 @@
|
|||
module github.com/codemicro/go-fiber-sql
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v2 v2.35.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rs/zerolog v1.27.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||
github.com/klauspost/compress v1.15.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.38.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 // indirect
|
||||
)
|
43
go.sum
Normal file
43
go.sum
Normal file
|
@ -0,0 +1,43 @@
|
|||
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofiber/fiber/v2 v2.35.0 h1:ct+jKw8Qb24WEIZx3VV3zz9VXyBZL7mcEjNaqj3g0h0=
|
||||
github.com/gofiber/fiber/v2 v2.35.0/go.mod h1:tgCr+lierLwLoVHHO/jn3Niannv34WRkQETU8wiL9fQ=
|
||||
github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U=
|
||||
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs=
|
||||
github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.38.0 h1:yTjSSNjuDi2PPvXY2836bIwLmiTS2T4T9p1coQshpco=
|
||||
github.com/valyala/fasthttp v1.38.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 h1:nhht2DYV/Sn3qOayu8lM+cU1ii9sTLUeBQwQQfUHtrs=
|
||||
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
Loading…
Add table
Add a link
Reference in a new issue