chore(project): bootstrap project tree
This commit is contained in:
3
internal/.gitignore
vendored
Normal file
3
internal/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/server.go
|
||||
/graph/generated
|
||||
/model/models_gen.go
|
127
internal/config/config.go
Normal file
127
internal/config/config.go
Normal file
@ -0,0 +1,127 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
|
||||
"github.com/caarlos0/env/v6"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
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
|
||||
func NewFromFile(filepath string) (*Config, error) {
|
||||
config := NewDefault()
|
||||
|
||||
data, err := ioutil.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not read file '%s'", filepath)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, config); err != nil {
|
||||
return nil, errors.Wrapf(err, "could not unmarshal configuration")
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
Address string `yaml:"address" env:"HTTP_ADDRESS"`
|
||||
CookieAuthenticationKey string `yaml:"cookieAuthenticationKey" env:"HTTP_COOKIE_AUTHENTICATION_KEY"`
|
||||
CookieEncryptionKey string `yaml:"cookieEncryptionKey" env:"HTTP_COOKIE_ENCRYPTION_KEY"`
|
||||
CookieMaxAge int `yaml:"cookieMaxAge" env:"HTTP_COOKIE_MAX_AGE"`
|
||||
TemplateDir string `yaml:"templateDir" env:"HTTP_TEMPLATE_DIR"`
|
||||
PublicDir string `yaml:"publicDir" env:"HTTP_PUBLIC_DIR"`
|
||||
FrontendURL string `yaml:"frontendURL" env:"HTTP_FRONTEND_URL"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
}
|
||||
|
||||
type CORSConfig struct {
|
||||
AllowedOrigins []string `yaml:"allowedOrigins" env:"HTTP_CORS_ALLOWED_ORIGINS"`
|
||||
AllowCredentials bool `yaml:"allowCredentials" env:"HTTP_CORS_ALLOW_CREDENTIALS"`
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
ClientID string `yaml:"clientId" env:"OIDC_CLIENT_ID"`
|
||||
ClientSecret string `yaml:"clientSecret" env:"OIDC_CLIENT_SECRET"`
|
||||
IssuerURL string `yaml:"issuerUrl" env:"OIDC_ISSUER_URL"`
|
||||
RedirectURL string `yaml:"redirectUrl" env:"OIDC_REDIRECT_URL"`
|
||||
PostLogoutRedirectURL string `yaml:"postLogoutRedirectURL" env:"OIDC_POST_LOGOUT_REDIRECT_URL"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level logger.Level `yaml:"level" env:"LOG_LEVEL"`
|
||||
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
|
||||
}
|
||||
|
||||
func NewDefault() *Config {
|
||||
return &Config{
|
||||
Debug: false,
|
||||
Log: LogConfig{
|
||||
Level: logger.LevelInfo,
|
||||
Format: logger.FormatHuman,
|
||||
},
|
||||
HTTP: HTTPConfig{
|
||||
Address: ":8081",
|
||||
CookieAuthenticationKey: "",
|
||||
CookieEncryptionKey: "",
|
||||
CookieMaxAge: int((time.Hour * 1).Seconds()), // 1 hour
|
||||
TemplateDir: "template",
|
||||
PublicDir: "public",
|
||||
FrontendURL: "http://localhost:8080",
|
||||
CORS: CORSConfig{
|
||||
AllowedOrigins: []string{"http://localhost:8080"},
|
||||
AllowCredentials: true,
|
||||
},
|
||||
},
|
||||
OIDC: OIDCConfig{
|
||||
IssuerURL: "http://localhost:4444/",
|
||||
RedirectURL: "http://localhost:8081/oauth2/callback",
|
||||
PostLogoutRedirectURL: "http://localhost:8081",
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
DSN: "host=localhost database=guesstimate",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Dump(config *Config, w io.Writer) error {
|
||||
data, err := yaml.Marshal(config)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not dump config")
|
||||
}
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WithEnvironment(conf *Config) error {
|
||||
if err := env.Parse(conf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
9
internal/config/provider.go
Normal file
9
internal/config/provider.go
Normal file
@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
import "gitlab.com/wpetit/goweb/service"
|
||||
|
||||
func ServiceProvider(config *Config) service.Provider {
|
||||
return func(ctn *service.Container) (interface{}, error) {
|
||||
return config, nil
|
||||
}
|
||||
}
|
33
internal/config/service.go
Normal file
33
internal/config/service.go
Normal file
@ -0,0 +1,33 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/service"
|
||||
)
|
||||
|
||||
const ServiceName service.Name = "config"
|
||||
|
||||
// From retrieves the config service in the given container
|
||||
func From(container *service.Container) (*Config, error) {
|
||||
service, err := container.Service(ServiceName)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
|
||||
}
|
||||
|
||||
srv, ok := service.(*Config)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Must retrieves the config service in the given container or panic otherwise
|
||||
func Must(container *service.Container) *Config {
|
||||
srv, err := From(container)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
56
internal/gqlgen.yml
Normal file
56
internal/gqlgen.yml
Normal file
@ -0,0 +1,56 @@
|
||||
# Where are all the schema files located? globs are supported eg src/**/*.graphqls
|
||||
schema:
|
||||
- graph/*.graphql
|
||||
|
||||
# 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: 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/guesstimate/internal/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
|
48
internal/graph/helper.go
Normal file
48
internal/graph/helper.go
Normal file
@ -0,0 +1,48 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/model"
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/orm"
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/session"
|
||||
"github.com/jinzhu/gorm"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/middleware/container"
|
||||
)
|
||||
|
||||
func getDB(ctx context.Context) (*gorm.DB, error) {
|
||||
ctn, err := container.From(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
orm, err := orm.From(ctn)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return orm.DB(), nil
|
||||
}
|
||||
|
||||
func getSessionUser(ctx context.Context) (*model.User, *gorm.DB, error) {
|
||||
db, err := getDB(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
userEmail, err := session.UserEmail(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
repo := model.NewUserRepository(db)
|
||||
|
||||
user, err := repo.FindUserByEmail(ctx, userEmail)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return user, db, nil
|
||||
}
|
7
internal/graph/mutation.graphql
Normal file
7
internal/graph/mutation.graphql
Normal file
@ -0,0 +1,7 @@
|
||||
input UserChanges {
|
||||
name: String
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
updateUser(id: ID!, changes: UserChanges!): User!
|
||||
}
|
20
internal/graph/mutation.resolvers.go
Normal file
20
internal/graph/mutation.resolvers.go
Normal file
@ -0,0 +1,20 @@
|
||||
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"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/graph/generated"
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/model"
|
||||
)
|
||||
|
||||
func (r *mutationResolver) UpdateUser(ctx context.Context, id string, changes model.UserChanges) (*model.User, error) {
|
||||
return handleUpdateUser(ctx, id, changes)
|
||||
}
|
||||
|
||||
// Mutation returns generated.MutationResolver implementation.
|
||||
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }
|
||||
|
||||
type mutationResolver struct{ *Resolver }
|
13
internal/graph/query.graphql
Normal file
13
internal/graph/query.graphql
Normal file
@ -0,0 +1,13 @@
|
||||
scalar Time
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
name: String
|
||||
email: String!
|
||||
connectedAt: Time!
|
||||
createdAt: Time!
|
||||
}
|
||||
|
||||
type Query {
|
||||
currentUser: User
|
||||
}
|
20
internal/graph/query.resolvers.go
Normal file
20
internal/graph/query.resolvers.go
Normal file
@ -0,0 +1,20 @@
|
||||
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"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/graph/generated"
|
||||
model1 "forge.cadoles.com/Cadoles/guesstimate/internal/model"
|
||||
)
|
||||
|
||||
func (r *queryResolver) CurrentUser(ctx context.Context) (*model1.User, error) {
|
||||
return handleCurrentUser(ctx)
|
||||
}
|
||||
|
||||
// Query returns generated.QueryResolver implementation.
|
||||
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }
|
||||
|
||||
type queryResolver struct{ *Resolver }
|
9
internal/graph/resolver.go
Normal file
9
internal/graph/resolver.go
Normal file
@ -0,0 +1,9 @@
|
||||
package graph
|
||||
|
||||
// This file will not be regenerated automatically.
|
||||
//
|
||||
// It serves as dependency injection for your app, add any dependencies you require here.
|
||||
|
||||
//go:generate go run github.com/99designs/gqlgen
|
||||
|
||||
type Resolver struct{}
|
46
internal/graph/user_handler.go
Normal file
46
internal/graph/user_handler.go
Normal file
@ -0,0 +1,46 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/model"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func handleCurrentUser(ctx context.Context) (*model.User, error) {
|
||||
user, _, err := getSessionUser(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func handleUpdateUser(ctx context.Context, id string, changes model.UserChanges) (*model.User, error) {
|
||||
user, db, err := getSessionUser(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if user.ID != id {
|
||||
graphql.AddError(ctx, gqlerror.Errorf("Forbidden"))
|
||||
}
|
||||
|
||||
repo := model.NewUserRepository(db)
|
||||
|
||||
userChanges := &model.User{}
|
||||
|
||||
if changes.Name != nil {
|
||||
userChanges.Name = changes.Name
|
||||
}
|
||||
|
||||
user, err = repo.UpdateUserByEmail(ctx, user.Email, userChanges)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
17
internal/model/user.go
Normal file
17
internal/model/user.go
Normal file
@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
ConnectedAt time.Time `json:"connectedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserChanges struct {
|
||||
Name *string `json:"name"`
|
||||
}
|
73
internal/model/user_repository.go
Normal file
73
internal/model/user_repository.go
Normal file
@ -0,0 +1,73 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/orm"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (r *UserRepository) CreateOrConnectUser(ctx context.Context, email string) (*User, error) {
|
||||
user := &User{
|
||||
Email: email,
|
||||
}
|
||||
|
||||
err := orm.WithTx(ctx, r.db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
err := tx.Where("email = ?", email).FirstOrCreate(user).Error
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := tx.Model(user).UpdateColumn("connected_at", time.Now()).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not create user")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
user := &User{
|
||||
Email: email,
|
||||
}
|
||||
|
||||
err := r.db.Model(user).First(user, "email = ?", email).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not find user")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) UpdateUserByEmail(ctx context.Context, email string, changes *User) (*User, error) {
|
||||
user := &User{
|
||||
Email: email,
|
||||
}
|
||||
|
||||
err := r.db.First(user, "email = ?", email).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not find user")
|
||||
}
|
||||
|
||||
if err := r.db.Model(user).Updates(changes).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "could not update user")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db}
|
||||
}
|
84
internal/orm/migration.go
Normal file
84
internal/orm/migration.go
Normal file
@ -0,0 +1,84 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/middleware/container"
|
||||
)
|
||||
|
||||
type MigrationFunc func(ctx context.Context, tx *gorm.DB) error
|
||||
|
||||
type Migration interface {
|
||||
Version() string
|
||||
Up(context.Context) error
|
||||
Down(context.Context) error
|
||||
}
|
||||
|
||||
type DBMigration struct {
|
||||
version string
|
||||
up MigrationFunc
|
||||
down MigrationFunc
|
||||
}
|
||||
|
||||
func (m *DBMigration) Version() string {
|
||||
return m.version
|
||||
}
|
||||
|
||||
func (m *DBMigration) Up(ctx context.Context) error {
|
||||
db, err := m.getDatabase(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = WithTx(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
return m.up(ctx, tx)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not apply up migration")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *DBMigration) Down(ctx context.Context) error {
|
||||
db, err := m.getDatabase(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = WithTx(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
return m.down(ctx, tx)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not apply down migration")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *DBMigration) getDatabase(ctx context.Context) (*gorm.DB, error) {
|
||||
ctn, err := container.From(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not retrieve service container")
|
||||
}
|
||||
|
||||
orm, err := From(ctn)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not retrieve orm service")
|
||||
}
|
||||
|
||||
return orm.DB(), nil
|
||||
}
|
||||
|
||||
func NewDBMigration(version string, up, down MigrationFunc) *DBMigration {
|
||||
return &DBMigration{
|
||||
version: version,
|
||||
up: up,
|
||||
down: down,
|
||||
}
|
||||
}
|
146
internal/orm/migration_manager.go
Normal file
146
internal/orm/migration_manager.go
Normal file
@ -0,0 +1,146 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoAvailableMigration = errors.New("no available migration")
|
||||
ErrMigrationNotFound = errors.New("migration not found")
|
||||
)
|
||||
|
||||
type MigrationManager struct {
|
||||
migrations []Migration
|
||||
resolver VersionResolver
|
||||
}
|
||||
|
||||
func (m *MigrationManager) 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 *MigrationManager) 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 *MigrationManager) 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 *MigrationManager) Register(migrations ...Migration) {
|
||||
m.migrations = migrations
|
||||
}
|
||||
|
||||
func (m *MigrationManager) CurrentVersion(ctx context.Context) (string, error) {
|
||||
return m.resolver.Current(ctx)
|
||||
}
|
||||
|
||||
func (m *MigrationManager) LatestVersion() (string, error) {
|
||||
if len(m.migrations) == 0 {
|
||||
return "", errors.WithStack(ErrNoAvailableMigration)
|
||||
}
|
||||
|
||||
return m.migrations[len(m.migrations)-1].Version(), nil
|
||||
}
|
||||
|
||||
func (m *MigrationManager) 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 NewMigrationManager(resolver VersionResolver) *MigrationManager {
|
||||
return &MigrationManager{
|
||||
resolver: resolver,
|
||||
migrations: make([]Migration, 0),
|
||||
}
|
||||
}
|
49
internal/orm/provider.go
Normal file
49
internal/orm/provider.go
Normal file
@ -0,0 +1,49 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/service"
|
||||
|
||||
// Import postgres dialect
|
||||
_ "github.com/jinzhu/gorm/dialects/postgres"
|
||||
)
|
||||
|
||||
func ServiceProvider(dialect, dsn string, debug bool) service.Provider {
|
||||
db, err := gorm.Open(dialect, dsn)
|
||||
if err != nil {
|
||||
err = errors.Wrap(err, "could not connect to database")
|
||||
}
|
||||
|
||||
var srv *Service
|
||||
|
||||
if err == nil {
|
||||
db = db.LogMode(debug)
|
||||
|
||||
versionResolver := NewDBVersionResolver(db)
|
||||
ctx := context.Background()
|
||||
|
||||
err := versionResolver.Init(ctx)
|
||||
if err != nil {
|
||||
err = errors.Wrap(err, "could not initialize version resolver")
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
srv = &Service{
|
||||
db: db,
|
||||
migration: NewMigrationManager(versionResolver),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return func(ctn *service.Container) (interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
}
|
47
internal/orm/service.go
Normal file
47
internal/orm/service.go
Normal file
@ -0,0 +1,47 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/service"
|
||||
)
|
||||
|
||||
const ServiceName service.Name = "orm"
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
migration *MigrationManager
|
||||
}
|
||||
|
||||
func (s *Service) DB() *gorm.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
func (s *Service) Migration() *MigrationManager {
|
||||
return s.migration
|
||||
}
|
||||
|
||||
// From retrieves the orm service in the given container.
|
||||
func From(container *service.Container) (*Service, error) {
|
||||
service, err := container.Service(ServiceName)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
|
||||
}
|
||||
|
||||
srv, ok := service.(*Service)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Must retrieves the orm pool service in the given container or panic otherwise.
|
||||
func Must(container *service.Container) *Service {
|
||||
srv, err := From(container)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
47
internal/orm/tx.go
Normal file
47
internal/orm/tx.go
Normal file
@ -0,0 +1,47 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func WithTx(ctx context.Context, db *gorm.DB, fn func(context.Context, *gorm.DB) error) error {
|
||||
tx := db.BeginTx(ctx, &sql.TxOptions{})
|
||||
|
||||
defer func() {
|
||||
if err := tx.Rollback().Error; err != nil && !isGormError(err, gorm.ErrInvalidTransaction) {
|
||||
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().Error; rollbackErr != nil {
|
||||
return errors.Wrap(err, rollbackErr.Error())
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return errors.Wrap(err, "could not commit transaction")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isGormError(err error, compErr error) bool {
|
||||
if errs, ok := err.(gorm.Errors); ok {
|
||||
for _, err := range errs {
|
||||
if errors.Is(err, compErr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Is(err, compErr)
|
||||
}
|
112
internal/orm/version_resolver.go
Normal file
112
internal/orm/version_resolver.go
Normal file
@ -0,0 +1,112 @@
|
||||
package orm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type VersionResolver interface {
|
||||
Current(context.Context) (string, error)
|
||||
Set(context.Context, string) error
|
||||
}
|
||||
|
||||
type DBVersionResolver struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type DatabaseVersion struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
Version string `gorm:"unique; not null"`
|
||||
MigratedAt time.Time
|
||||
IsCurrent bool
|
||||
}
|
||||
|
||||
func (r *DBVersionResolver) Current(ctx context.Context) (string, error) {
|
||||
var version string
|
||||
|
||||
err := WithTx(ctx, r.db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
dbVersion := &DatabaseVersion{}
|
||||
err := tx.Where("is_current = ?", true).First(dbVersion).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
version = dbVersion.Version
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "could execute version resolver init transaction")
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (r *DBVersionResolver) Set(ctx context.Context, version string) error {
|
||||
err := WithTx(ctx, r.db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
dbVersion := &DatabaseVersion{
|
||||
Version: version,
|
||||
MigratedAt: time.Now(),
|
||||
}
|
||||
|
||||
if version != "" {
|
||||
if err := tx.FirstOrCreate(dbVersion).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := tx.Model(dbVersion).
|
||||
UpdateColumn("is_current", true).Error
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
err := tx.Model(&DatabaseVersion{}).
|
||||
Where("version <> ?", version).
|
||||
UpdateColumn("is_current", false).Error
|
||||
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not update schema version")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DBVersionResolver) Init(ctx context.Context) error {
|
||||
err := WithTx(ctx, r.db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&DatabaseVersion{}).Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
if err := tx.Model(&DatabaseVersion{}).AddUniqueIndex("idx_unique_version", "version").Error; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could execute version resolver init transaction")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewDBVersionResolver(db *gorm.DB) *DBVersionResolver {
|
||||
return &DBVersionResolver{
|
||||
db: db,
|
||||
}
|
||||
}
|
77
internal/route/login.go
Normal file
77
internal/route/login.go
Normal file
@ -0,0 +1,77 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/model"
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/orm"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/session"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/config"
|
||||
oidc "forge.cadoles.com/wpetit/goweb-oidc"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
"gitlab.com/wpetit/goweb/middleware/container"
|
||||
)
|
||||
|
||||
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ctn := container.Must(r.Context())
|
||||
client := oidc.Must(ctn)
|
||||
client.Login(w, r)
|
||||
}
|
||||
|
||||
type emailClaims struct {
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
}
|
||||
|
||||
func handleLoginCallback(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctn := container.Must(ctx)
|
||||
conf := config.Must(ctn)
|
||||
|
||||
idToken, err := oidc.IDToken(w, r)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not retrieve idToken", logger.E(err))
|
||||
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info(ctx, "user logged in", logger.F("sub", idToken.Subject))
|
||||
|
||||
claims := &emailClaims{}
|
||||
if err := idToken.Claims(claims); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
// TODO implements better UX in case of errors
|
||||
|
||||
if claims.Email == "" {
|
||||
http.Error(w, "an email is expected to access this app", http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !claims.EmailVerified {
|
||||
http.Error(w, "your email must be verified to access this app", http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
db := orm.Must(ctn).DB()
|
||||
repo := model.NewUserRepository(db)
|
||||
|
||||
if _, err := repo.CreateOrConnectUser(ctx, claims.Email); err != nil {
|
||||
panic(errors.Wrap(err, "could not upsert user"))
|
||||
}
|
||||
|
||||
if err := session.SaveUserEmail(w, r, claims.Email); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
http.Redirect(w, r, conf.HTTP.FrontendURL, http.StatusSeeOther)
|
||||
}
|
40
internal/route/logout.go
Normal file
40
internal/route/logout.go
Normal file
@ -0,0 +1,40 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/session"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/config"
|
||||
oidc "forge.cadoles.com/wpetit/goweb-oidc"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
"gitlab.com/wpetit/goweb/middleware/container"
|
||||
)
|
||||
|
||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctn := container.Must(ctx)
|
||||
conf := config.Must(ctn)
|
||||
client := oidc.Must(ctn)
|
||||
|
||||
logger.Info(
|
||||
ctx,
|
||||
"logging out user",
|
||||
logger.F("postLogoutURL", conf.OIDC.PostLogoutRedirectURL),
|
||||
)
|
||||
|
||||
if err := session.ClearUserEmail(w, r, false); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
client.Logout(w, r, conf.OIDC.PostLogoutRedirectURL)
|
||||
}
|
||||
|
||||
func handleLogoutRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctn := container.Must(ctx)
|
||||
conf := config.Must(ctn)
|
||||
|
||||
http.Redirect(w, r, conf.HTTP.FrontendURL, http.StatusSeeOther)
|
||||
}
|
79
internal/route/mount.go
Normal file
79
internal/route/mount.go
Normal file
@ -0,0 +1,79 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/config"
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/graph"
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/graph/generated"
|
||||
"forge.cadoles.com/Cadoles/guesstimate/internal/session"
|
||||
oidc "forge.cadoles.com/wpetit/goweb-oidc"
|
||||
"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/gorilla/websocket"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/rs/cors"
|
||||
"gitlab.com/wpetit/goweb/static"
|
||||
)
|
||||
|
||||
func Mount(r *chi.Mux, config *config.Config) error {
|
||||
|
||||
r.With(oidc.HandleCallback).Get("/oauth2/callback", handleLoginCallback)
|
||||
r.Get("/logout", handleLogout)
|
||||
r.Get("/login", handleLogin)
|
||||
r.Get("/logout/redirect", handleLogoutRedirect)
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(cors.New(cors.Options{
|
||||
AllowedOrigins: config.HTTP.CORS.AllowedOrigins,
|
||||
AllowCredentials: config.HTTP.CORS.AllowCredentials,
|
||||
Debug: config.Debug,
|
||||
}).Handler)
|
||||
r.Use(session.UserEmailMiddleware)
|
||||
|
||||
gql := handler.New(
|
||||
generated.NewExecutableSchema(generated.Config{
|
||||
Resolvers: &graph.Resolver{},
|
||||
}),
|
||||
)
|
||||
|
||||
gql.AddTransport(transport.POST{})
|
||||
gql.AddTransport(&transport.Websocket{
|
||||
KeepAlivePingInterval: 10 * time.Second,
|
||||
Upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// TODO Check WS connection origin
|
||||
return true
|
||||
},
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
},
|
||||
})
|
||||
|
||||
if config.Debug {
|
||||
gql.Use(extension.Introspection{})
|
||||
r.Get("/v1/playground", playground.Handler("GraphQL playground", "/api/v1/graphql"))
|
||||
}
|
||||
|
||||
r.Handle("/v1/graphql", gql)
|
||||
})
|
||||
|
||||
clientIndex := path.Join(config.HTTP.PublicDir, "index.html")
|
||||
|
||||
serveClientIndex := func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, clientIndex)
|
||||
}
|
||||
|
||||
r.Get("/profile", serveClientIndex)
|
||||
|
||||
// Serve static files
|
||||
notFoundHandler := r.NotFoundHandler()
|
||||
r.Get("/*", static.Dir(config.HTTP.PublicDir, "", notFoundHandler))
|
||||
|
||||
return nil
|
||||
}
|
116
internal/session/user_email.go
Normal file
116
internal/session/user_email.go
Normal file
@ -0,0 +1,116 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/middleware/container"
|
||||
"gitlab.com/wpetit/goweb/service/session"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userEmailKey contextKey = "user_email"
|
||||
|
||||
var (
|
||||
ErrUserEmailNotFound = errors.New("user email not found")
|
||||
)
|
||||
|
||||
func UserEmailMiddleware(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
userEmail, err := GetUserEmail(w, r)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx := WithUserEmail(r.Context(), userEmail)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
|
||||
func WithUserEmail(ctx context.Context, email string) context.Context {
|
||||
return context.WithValue(ctx, userEmailKey, email)
|
||||
}
|
||||
|
||||
func UserEmail(ctx context.Context) (string, error) {
|
||||
email, ok := ctx.Value(userEmailKey).(string)
|
||||
if !ok {
|
||||
return "", errors.WithStack(ErrUserEmailNotFound)
|
||||
}
|
||||
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func SaveUserEmail(w http.ResponseWriter, r *http.Request, email string) error {
|
||||
sess, err := getSession(w, r)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
sess.Set(string(userEmailKey), email)
|
||||
|
||||
if err := sess.Save(w, r); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearUserEmail(w http.ResponseWriter, r *http.Request, saveSession bool) error {
|
||||
sess, err := getSession(w, r)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
sess.Unset(string(userEmailKey))
|
||||
|
||||
if saveSession {
|
||||
if err := sess.Save(w, r); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetUserEmail(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||
sess, err := getSession(w, r)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
email, ok := sess.Get(string(userEmailKey)).(string)
|
||||
if !ok {
|
||||
return "", errors.WithStack(ErrUserEmailNotFound)
|
||||
}
|
||||
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func getSession(w http.ResponseWriter, r *http.Request) (session.Session, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
ctn, err := container.From(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
session, err := session.From(ctn)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
sess, err := session.Get(w, r)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return sess, nil
|
||||
}
|
Reference in New Issue
Block a user