Intégration d'un point d'entrée GraphQL et d'un connecteur pour

PostgreSQL

- Possibilité de migrer le schéma de la base de données via drapeau
- Génération du code GraphQL avec https://gqlgen.com/
This commit is contained in:
2020-07-13 09:20:14 +02:00
parent 1120474ad9
commit 591112a800
28 changed files with 984 additions and 15 deletions

2
internal/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/server.go
/graph/generated

View File

@ -13,9 +13,11 @@ import (
)
type Config struct {
Log LogConfig `yaml:"log"`
HTTP HTTPConfig `yaml:"http"`
OIDC OIDCConfig `yaml:"oidc"`
Debug bool `yaml:"debug" env:"DEBUG"`
Log LogConfig `yaml:"log"`
HTTP HTTPConfig `yaml:"http"`
OIDC OIDCConfig `yaml:"oidc"`
Database DatabaseConfig `yaml:"database"`
}
// NewFromFile retrieves the configuration from the given file
@ -57,6 +59,10 @@ type LogConfig struct {
Format logger.Format `yaml:"format" env:"LOG_FORMAT"`
}
type DatabaseConfig struct {
DSN string `yaml:"dsn" env:"DATABASE_DSN"`
}
func NewDumpDefault() *Config {
config := NewDefault()
return config
@ -64,6 +70,7 @@ func NewDumpDefault() *Config {
func NewDefault() *Config {
return &Config{
Debug: false,
Log: LogConfig{
Level: logger.LevelInfo,
Format: logger.FormatHuman,
@ -82,6 +89,9 @@ func NewDefault() *Config {
RedirectURL: "http://localhost:8081/oauth2/callback",
PostLogoutRedirectURL: "http://localhost:8081",
},
Database: DatabaseConfig{
DSN: "host=localhost database=daddy",
},
}
}

View File

@ -0,0 +1,79 @@
package database
import (
"context"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/middleware/container"
)
type MigrationFunc func(ctx context.Context, tx pgx.Tx) error
type Migration struct {
version string
up MigrationFunc
down MigrationFunc
}
func (m *Migration) Version() string {
return m.version
}
func (m *Migration) Up(ctx context.Context) error {
pool, err := m.getDatabaseService(ctx)
if err != nil {
return err
}
err = WithTx(ctx, pool, func(ctx context.Context, tx pgx.Tx) error {
return m.up(ctx, tx)
})
if err != nil {
return errors.Wrap(err, "could not apply up migration")
}
return nil
}
func (m *Migration) Down(ctx context.Context) error {
pool, err := m.getDatabaseService(ctx)
if err != nil {
return err
}
err = WithTx(ctx, pool, func(ctx context.Context, tx pgx.Tx) error {
return m.down(ctx, tx)
})
if err != nil {
return errors.Wrap(err, "could not apply down migration")
}
return nil
}
func (m *Migration) getDatabaseService(ctx context.Context) (*pgxpool.Pool, error) {
ctn, err := container.From(ctx)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve service container")
}
pool, err := From(ctn)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve database service")
}
return pool, nil
}
func NewMigration(version string, up, down MigrationFunc) *Migration {
return &Migration{
version: version,
up: up,
down: down,
}
}

View File

@ -0,0 +1,24 @@
package database
import (
"context"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
func ServiceProvider(dsn string) service.Provider {
pool, err := pgxpool.Connect(context.Background(), dsn)
if err != nil {
err = errors.Wrap(err, "could not connect to database")
}
return func(ctn *service.Container) (interface{}, error) {
if err != nil {
return nil, err
}
return pool, nil
}
}

View File

@ -0,0 +1,34 @@
package database
import (
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
const ServiceName service.Name = "database"
// From retrieves the database pool service in the given container
func From(container *service.Container) (*pgxpool.Pool, error) {
service, err := container.Service(ServiceName)
if err != nil {
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
}
srv, ok := service.(*pgxpool.Pool)
if !ok {
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
}
return srv, nil
}
// Must retrieves the database pool service in the given container or panic otherwise
func Must(container *service.Container) *pgxpool.Pool {
srv, err := From(container)
if err != nil {
panic(err)
}
return srv
}

38
internal/database/tx.go Normal file
View File

@ -0,0 +1,38 @@
package database
import (
"context"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pkg/errors"
)
func WithTx(ctx context.Context, pool *pgxpool.Pool, fn func(context.Context, pgx.Tx) error) error {
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return errors.Wrap(err, "could not begin transaction")
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
panic(errors.Wrap(err, "could not rollback transaction"))
}
}()
if err := fn(ctx, tx); err != nil {
err := errors.Wrap(err, "could not apply down migration")
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil {
return errors.Wrap(err, rollbackErr.Error())
}
return err
}
if err := tx.Commit(ctx); err != nil {
return errors.Wrap(err, "could not commit transaction")
}
return nil
}

View File

@ -0,0 +1,94 @@
package database
import (
"context"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/pkg/errors"
)
type VersionResolver struct {
pool *pgxpool.Pool
}
func (r *VersionResolver) Current(ctx context.Context) (string, error) {
var version string
err := WithTx(ctx, r.pool, func(ctx context.Context, tx pgx.Tx) error {
err := tx.QueryRow(ctx, `SELECT version FROM database_schema WHERE is_current = true;`).
Scan(&version)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return err
})
if err != nil {
return "", errors.Wrap(err, "could execute version resolver init transaction")
}
return version, nil
}
func (r *VersionResolver) Set(ctx context.Context, version string) error {
err := WithTx(ctx, r.pool, func(ctx context.Context, tx pgx.Tx) error {
if version != "" {
_, err := tx.Exec(ctx, `
INSERT INTO database_schema (version, is_current, migrated_at)
VALUES
(
$1,
true,
now()
)
ON CONFLICT ON CONSTRAINT unique_version
DO UPDATE SET migrated_at = now(), is_current = true;
`, version)
if err != nil {
return err
}
}
_, err := tx.Exec(ctx, `
UPDATE database_schema SET is_current = false, migrated_at = null WHERE version <> $1;
`, version)
return err
})
if err != nil {
return errors.Wrap(err, "could not update schema version")
}
return nil
}
func (r *VersionResolver) Init(ctx context.Context) error {
err := WithTx(ctx, r.pool, func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
CREATE TABLE IF NOT EXISTS database_schema(
version TEXT NOT NULL,
migrated_at TIME,
is_current BOOLEAN,
CONSTRAINT unique_version UNIQUE(version)
);`)
return err
})
if err != nil {
return errors.Wrap(err, "could execute version resolver init transaction")
}
return nil
}
func NewVersionResolver(pool *pgxpool.Pool) *VersionResolver {
return &VersionResolver{
pool: pool,
}
}

56
internal/gqlgen.yml Normal file
View File

@ -0,0 +1,56 @@
# Where are all the schema files located? globs are supported eg src/**/*.graphqls
schema:
- graph/*.graphqls
# Where should the generated server code go?
exec:
filename: graph/generated/generated.go
package: generated
# Uncomment to enable federation
# federation:
# filename: graph/generated/federation.go
# package: generated
# Where should any generated models go?
model:
filename: graph/model/models_gen.go
package: model
# Where should the resolver implementations go?
resolver:
layout: follow-schema
dir: graph
package: graph
# Optional: turn on use `gqlgen:"fieldName"` tags in your models
# struct_tag: json
# Optional: turn on to use []Thing instead of []*Thing
# omit_slice_element_pointers: false
# Optional: set to speed up generation time by not performing a final validation pass.
# skip_validation: true
# gqlgen will search for any type names in the schema in these go packages
# if they match it will use them, otherwise it will generate them.
autobind:
- "forge.cadoles.com/Cadoles/daddy/internal/graph/model"
# This section declares type mapping between the GraphQL and go type systems
#
# The first line in each type will be used as defaults for resolver arguments and
# modelgen, the others will be allowed when binding to fields. Configure them to
# your liking
models:
ID:
model:
- github.com/99designs/gqlgen/graphql.ID
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32
Int:
model:
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32

View File

@ -0,0 +1,8 @@
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package model
type User struct {
Name *string `json:"name"`
Email string `json:"email"`
}

View File

@ -0,0 +1,7 @@
package graph
// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.
type Resolver struct{}

View File

@ -0,0 +1,12 @@
# GraphQL schema example
#
# https://gqlgen.com/getting-started/
type User {
name: String
email: String!
}
type Query {
userProfile: User
}

View File

@ -0,0 +1,21 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
import (
"context"
"fmt"
"forge.cadoles.com/Cadoles/daddy/internal/graph/generated"
"forge.cadoles.com/Cadoles/daddy/internal/graph/model"
)
func (r *queryResolver) UserProfile(ctx context.Context) (*model.User, error) {
panic(fmt.Errorf("not implemented"))
}
// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
type queryResolver struct{ *Resolver }

View File

@ -0,0 +1,146 @@
package migration
import (
"context"
"github.com/pkg/errors"
)
var (
ErrNoAvailableMigration = errors.New("no available migration")
ErrMigrationNotFound = errors.New("migration not found")
)
type Manager struct {
migrations []Migration
resolver VersionResolver
}
func (m *Manager) Up(ctx context.Context) error {
currentVersion, err := m.resolver.Current(ctx)
if err != nil {
return errors.Wrap(err, "could not retrieve current version")
}
migrate := func(up Migration) error {
if err := up.Up(ctx); err != nil {
return errors.Wrapf(err, "could not apply '%s' up migration", up.Version())
}
if err := m.resolver.Set(ctx, up.Version()); err != nil {
return errors.Wrapf(err, "could not update schema version to '%s'", up.Version())
}
return nil
}
if currentVersion == "" {
up := m.migrations[0]
return migrate(up)
}
for i, mi := range m.migrations {
if mi.Version() != currentVersion && currentVersion != "" {
continue
}
// Already at latest, do nothing
if i >= len(m.migrations)-1 {
return nil
}
up := m.migrations[i+1]
return migrate(up)
}
return errors.WithStack(ErrMigrationNotFound)
}
func (m *Manager) Down(ctx context.Context) error {
currentVersion, err := m.resolver.Current(ctx)
if err != nil {
return errors.Wrap(err, "could not retrieve current version")
}
for i, mi := range m.migrations {
if mi.Version() != currentVersion {
continue
}
if err := mi.Down(ctx); err != nil {
return errors.Wrapf(err, "could not apply '%s' down migration", mi.Version())
}
var version string
// Already at oldest, do nothing
if i != 0 {
down := m.migrations[i-1]
version = down.Version()
}
if err := m.resolver.Set(ctx, version); err != nil {
return errors.Wrapf(err, "could not update schema version to '%s'", version)
}
return nil
}
return errors.WithStack(ErrMigrationNotFound)
}
func (m *Manager) Latest(ctx context.Context) error {
for {
isLatest, err := m.IsLatest(ctx)
if err != nil {
return errors.Wrap(err, "could not retrieve schema state")
}
if isLatest {
return nil
}
if err := m.Up(ctx); err != nil {
return errors.WithStack(err)
}
}
}
func (m *Manager) Register(migrations ...Migration) {
m.migrations = migrations
}
func (m *Manager) CurrentVersion(ctx context.Context) (string, error) {
return m.resolver.Current(ctx)
}
func (m *Manager) LatestVersion() (string, error) {
if len(m.migrations) == 0 {
return "", errors.WithStack(ErrNoAvailableMigration)
}
return m.migrations[len(m.migrations)-1].Version(), nil
}
func (m *Manager) IsLatest(ctx context.Context) (bool, error) {
currentVersion, err := m.resolver.Current(ctx)
if err != nil {
return false, errors.Wrap(err, "could not retrieve current version")
}
latestVersion, err := m.LatestVersion()
if err != nil {
return false, errors.Wrap(err, "could not retrieve latest version")
}
return currentVersion == latestVersion, nil
}
func NewManager(resolver VersionResolver) *Manager {
return &Manager{
resolver: resolver,
migrations: make([]Migration, 0),
}
}

View File

@ -0,0 +1,9 @@
package migration
import "context"
type Migration interface {
Version() string
Up(context.Context) error
Down(context.Context) error
}

View File

@ -0,0 +1,13 @@
package migration
import (
"gitlab.com/wpetit/goweb/service"
)
func ServiceProvider(resolver VersionResolver) service.Provider {
manager := NewManager(resolver)
return func(ctn *service.Container) (interface{}, error) {
return manager, nil
}
}

View File

@ -0,0 +1,33 @@
package migration
import (
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
const ServiceName service.Name = "migration"
// From retrieves the migration service in the given container.
func From(container *service.Container) (*Manager, error) {
service, err := container.Service(ServiceName)
if err != nil {
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
}
srv, ok := service.(*Manager)
if !ok {
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
}
return srv, nil
}
// Must retrieves the migration service in the given container or panic otherwise.
func Must(container *service.Container) *Manager {
srv, err := From(container)
if err != nil {
panic(err)
}
return srv
}

View File

@ -0,0 +1,8 @@
package migration
import "context"
type VersionResolver interface {
Current(context.Context) (string, error)
Set(context.Context, string) error
}

View File

@ -2,7 +2,11 @@ package route
import (
"forge.cadoles.com/Cadoles/daddy/internal/config"
"forge.cadoles.com/Cadoles/daddy/internal/graph"
"forge.cadoles.com/Cadoles/daddy/internal/graph/generated"
oidc "forge.cadoles.com/wpetit/goweb-oidc"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/go-chi/chi"
"gitlab.com/wpetit/goweb/static"
@ -18,6 +22,17 @@ func Mount(r *chi.Mux, config *config.Config) error {
r.Route("/api", func(r chi.Router) {
r.Use(oidc.Middleware)
gql := handler.NewDefaultServer(
generated.NewExecutableSchema(generated.Config{
Resolvers: &graph.Resolver{},
}),
)
if config.Debug {
r.Get("/v1/graphql", playground.Handler("GraphQL playground", "/api/v1/graphql"))
}
r.Post("/v1/graphql", gql.ServeHTTP)
})
notFoundHandler := r.NotFoundHandler()