diff --git a/down.dev.sh b/down.dev.sh new file mode 100755 index 0000000..5e4f577 --- /dev/null +++ b/down.dev.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -eo + +docker compose down -v diff --git a/log.dev.sh b/log.dev.sh new file mode 100755 index 0000000..064130c --- /dev/null +++ b/log.dev.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -eo + +docker compose logs app -f diff --git a/scripts/scratch.http b/scripts/scratch.http new file mode 100644 index 0000000..91aa360 --- /dev/null +++ b/scripts/scratch.http @@ -0,0 +1,48 @@ +POST http://localhost:8080/auth/register +Content-Type: application/json + +{ + "email": "some-user-email@gmail.com", + "password": "some-password" +} + +> {% +client.test("Request executed successfully", function() { + client.assert(response.status === 201, "response status is not 200") +}) + client.test("Response content-type is json", function() { + const type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + %} + +### + +POST http://localhost:8080/projects/ +Content-Type: application/json +Authorization: Basic some-user-email@gmail.com some-password + +{ + "name": "some-project-name" +} + +> {% +client.test("Request executed successfully", function() { + client.assert(response.status === 201, "response status is not 200") +}) + client.test("Response content-type is json", function() { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); + %} + +### + +POST http://localhost:8080/applications/ +Content-Type: application/json +Authorization: Basic some-user-email@gmail.com some-password + +{ + "projectId": 1, + "name": "some-application-name" +} \ No newline at end of file diff --git a/services/db/migrations/006_add_application.sql b/services/db/migrations/006_add_application.sql new file mode 100644 index 0000000..3925e6e --- /dev/null +++ b/services/db/migrations/006_add_application.sql @@ -0,0 +1,18 @@ +-- Write your migrate up statements here + +create table sctl_application +( + id int GENERATED BY DEFAULT AS IDENTITY primary key, + name varchar(30) not null, + project_id int not null, + constraint fk_project + foreign key (project_id) + REFERENCES sctl_project (id) ON DELETE CASCADE +); + +---- create above / drop below ---- + +drop table sctl_application; + +-- Write your migrate down statements here. If this migration is irreversible +-- Then delete the separator line above. diff --git a/services/entry/main.go b/services/entry/main.go index 83fa0a4..647b572 100644 --- a/services/entry/main.go +++ b/services/entry/main.go @@ -12,7 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/go-co-op/gocron" "github.com/google/uuid" - "github.com/jackc/pgx/v4/pgxpool" + "github.com/jackc/pgx/v4" "github.com/prometheus/client_golang/prometheus/promhttp" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -20,6 +20,7 @@ import ( "log" "net/http" "os" + "serverctl/pkg/application/applications" "serverctl/pkg/application/projects" "serverctl/pkg/application/users" "serverctl/pkg/db" @@ -85,7 +86,7 @@ func BasicAuthMiddleware(l *zap.Logger, us *users.Service) gin.HandlerFunc { c.Next() } } -func setupApi(l *zap.Logger, cc *cache.MetricCache, us *users.Service, ps *projects.Service) { +func setupApi(l *zap.Logger, cc *cache.MetricCache, us *users.Service, ps *projects.Service, as *applications.Service) { l.Info("Setting up serverctl setupApi (using gin)") r := gin.Default() @@ -180,6 +181,28 @@ func setupApi(l *zap.Logger, cc *cache.MetricCache, us *users.Service, ps *proje c.JSON(http.StatusOK, getProject) }) + applications := r.Group("/applications", BasicAuthMiddleware(l, us)) + applications.POST("/", func(c *gin.Context) { + type CreateApplicationRequest struct { + ProjectId int `json:"projectId" binding:"required"` + Name string `json:"name" binding:"required"` + } + + var createApplicationRequest CreateApplicationRequest + if err := c.BindJSON(&createApplicationRequest); err != nil { + return + } + + userId, _ := c.Get("userId") + applicationId, err := as.CreateApplication(c.Request.Context(), createApplicationRequest.Name, userId.(int), createApplicationRequest.ProjectId) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "you have provided invalid input"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": "application has been created", "applicationId": applicationId}) + }) + containers := r.Group("/containers", BasicAuthMiddleware(l, us)) containers.GET("/", func(c *gin.Context) { type container struct { @@ -274,10 +297,12 @@ func main() { usersService := users.NewService(logger, usersRepository, cacheM) projectsRepository := postgres.NewProjectsRepository(database) projectsService := projects.NewService(logger, projectsRepository, cacheM) + applicationsRepository := postgres.NewApplicationRepository(logger, database) + applicationsService := applications.NewService(logger, applicationsRepository) setupProfiler() - addSeedData(database, usersRepository, projectsRepository) - setupApi(logger, cacheM, usersService, projectsService) + addSeedData(database, usersRepository, projectsRepository, logger) + setupApi(logger, cacheM, usersService, projectsService, applicationsService) } func setupProfiler() { @@ -286,7 +311,7 @@ func setupProfiler() { }() } -func addSeedData(database *db.Client, ur users.Repository, pr projects.Repository) { +func addSeedData(database *db.Client, ur users.Repository, pr projects.Repository, logger *zap.Logger) { conn := database.GetConn(context.Background()) defer conn.Release() @@ -297,35 +322,42 @@ func addSeedData(database *db.Client, ur users.Repository, pr projects.Repositor } if numRows == 0 { - addTestData(conn, ur, pr) + addTestData(database, ur, pr, logger) } } -func addTestData(conn *pgxpool.Conn, ur users.Repository, pr projects.Repository) { +func addTestData(database *db.Client, ur users.Repository, pr projects.Repository, logger *zap.Logger) { ctx := context.Background() - for jobs := 0; jobs < 100; jobs++ { - go func() { - for i := 0; i < 1_000; i++ { + + for jobs := 0; jobs < 10; jobs++ { + go func(batchNr int) { + conn := database.GetConn(ctx) + defer conn.Release() + batch := &pgx.Batch{} + numInserts := 5_000 + for i := 0; i < numInserts; i++ { var ( - user *users.CreateUser - err error - userId int + user *users.CreateUser + err error ) + user, err = users.NewCreateUser(fmt.Sprintf("%s@test.com", uuid.New().String()), "password", users.NewPlainTextPasswordHasher()) if err != nil { panic(err) } - userId, err = ur.Create(ctx, user) + batch.Queue("INSERT INTO sctl_user(email, password_hash) values ($1, $2)", user.Email, user.PasswordHash) + } + res := conn.SendBatch(ctx, batch) + for i := 0; i < numInserts; i++ { + _, err := res.Exec() if err != nil { - panic(err) - } - - _, err = pr.Create(ctx, projects.NewCreateProject(uuid.New().String()[:20], userId)) - if err != nil { - panic(err) + return } } - }() + + logger.Debug("sent batch", + zap.Int("batchId", batchNr)) + }(jobs) } } diff --git a/services/entry/pkg/application/applications/model.go b/services/entry/pkg/application/applications/model.go new file mode 100644 index 0000000..003a1e6 --- /dev/null +++ b/services/entry/pkg/application/applications/model.go @@ -0,0 +1,15 @@ +package applications + +type Application struct { + Id int + ProjectId int + Name string +} + +func NewApplication(id int, projectId int, name string) *Application { + return &Application{ + Id: id, + ProjectId: projectId, + Name: name, + } +} diff --git a/services/entry/pkg/application/applications/repository.go b/services/entry/pkg/application/applications/repository.go new file mode 100644 index 0000000..588969f --- /dev/null +++ b/services/entry/pkg/application/applications/repository.go @@ -0,0 +1,7 @@ +package applications + +import "context" + +type Repository interface { + CreateApplication(ctx context.Context, name string, userId int, projectId int) (int, error) +} diff --git a/services/entry/pkg/application/applications/service.go b/services/entry/pkg/application/applications/service.go new file mode 100644 index 0000000..51ea306 --- /dev/null +++ b/services/entry/pkg/application/applications/service.go @@ -0,0 +1,29 @@ +package applications + +import ( + "context" + "errors" + "go.uber.org/zap" +) + +type Service struct { + repository Repository + logger *zap.Logger +} + +func NewService(logger *zap.Logger, repository Repository) *Service { + return &Service{ + logger: logger, + repository: repository, + } +} + +func (s Service) CreateApplication(ctx context.Context, applicationName string, userId int, projectId int) (int, error) { + if applicationName == "" { + return -1, errors.New("application name is empty") + } + + applicationId, err := s.repository.CreateApplication(ctx, applicationName, userId, projectId) + + return applicationId, err +} diff --git a/services/entry/pkg/db/postgres/applicationsRepository.go b/services/entry/pkg/db/postgres/applicationsRepository.go new file mode 100644 index 0000000..d666137 --- /dev/null +++ b/services/entry/pkg/db/postgres/applicationsRepository.go @@ -0,0 +1,61 @@ +package postgres + +import ( + "context" + "errors" + "github.com/jackc/pgx/v4" + "go.uber.org/zap" + "serverctl/pkg/application/applications" + "serverctl/pkg/db" +) + +type ApplicationRepository struct { + db *db.Client + logger *zap.Logger +} + +var _ applications.Repository = ApplicationRepository{} + +func NewApplicationRepository(logger *zap.Logger, db *db.Client) applications.Repository { + return &ApplicationRepository{logger: logger, db: db} +} + +func (a ApplicationRepository) CreateApplication(ctx context.Context, name string, userId int, projectId int) (int, error) { + conn := a.db.GetConn(ctx) + defer conn.Release() + + var applicationId int + err := conn.BeginTxFunc(ctx, pgx.TxOptions{}, func(tx pgx.Tx) error { + var exists bool + err := tx.QueryRow( + ctx, + "select exists(select 1 from sctl_project_member where project_id = $1 and member_id = $2 and role = 'admin')", projectId, userId, + ).Scan(&exists) + if err != nil { + a.logger.Info("cannot query project member status in database", + zap.Int("userId", userId), + zap.Int("projectId", projectId), + zap.String("error", err.Error())) + return err + } + if !exists { + a.logger.Info("cannot create application as user isn't admin for project, or project doesn't exist", + zap.Int("userId", userId), + zap.Int("projectId", projectId)) + return errors.New("user isn't admin or admin of project") + } + + err = tx.QueryRow(ctx, "insert into sctl_application(name, project_id) values($1, $2) returning id", name, projectId).Scan(&applicationId) + if err != nil { + a.logger.Info("could not create application", zap.String("error", err.Error())) + return err + } + + return nil + }) + if err != nil { + return -1, err + } + + return applicationId, nil +} diff --git a/up.dev.sh b/up.dev.sh new file mode 100755 index 0000000..40514db --- /dev/null +++ b/up.dev.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -eo + +docker compose up -d --build + +docker compose logs app -f