chore: initial commit

This commit is contained in:
2024-04-16 22:27:52 +01:00
commit 531b5dabe2
194 changed files with 27071 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"context"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/rs/zerolog/log"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/auth/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/auth"
)
func PrepareGateway(cfg *auth.ServiceConfig) *runtime.ServeMux {
mux, clientOpts := serve.NewGatewayServeBase(&cfg.Shared)
ctx := context.Background()
err := pb.RegisterAuthServiceHandlerFromEndpoint(ctx, mux, serve.GetAddrToGrpc("localhost"), clientOpts)
if err != nil {
log.Panic().Err(err).Msg("failed to register endpoint for gateway")
}
return mux
}

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"google.golang.org/grpc"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/auth/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/auth"
)
func PrepareGrpc(cfg *auth.ServiceConfig, svc *auth.AuthService) *grpc.Server {
svr := serve.NewGrpcServeBase(&cfg.Shared)
pb.RegisterAuthServiceServer(svr, svc)
return svr
}

157
internal/svc/auth/auth.go Normal file
View File

@@ -0,0 +1,157 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"context"
"github.com/bufbuild/protovalidate-go"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/hexolan/stocklet/internal/pkg/errors"
"github.com/hexolan/stocklet/internal/pkg/gwauth"
"github.com/hexolan/stocklet/internal/pkg/messaging"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/auth/v1"
commonpb "github.com/hexolan/stocklet/internal/pkg/protogen/common/v1"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
)
// Interface for the service
type AuthService struct {
pb.UnimplementedAuthServiceServer
cfg *ServiceConfig
store StorageController
pbVal *protovalidate.Validator
}
// Interface for database methods
// Allows implementing seperate controllers for different databases (e.g. Postgres, MongoDB, etc)
type StorageController interface {
SetPassword(ctx context.Context, userId string, password string) error
VerifyPassword(ctx context.Context, userId string, password string) (bool, error)
DeleteAuthMethods(ctx context.Context, userId string) error
}
// Interface for event consumption
// Flexibility for seperate controllers for different messaging systems (e.g. Kafka, NATS, etc)
type ConsumerController interface {
messaging.ConsumerController
Attach(svc pb.AuthServiceServer)
}
// Create the auth service
func NewAuthService(cfg *ServiceConfig, store StorageController) *AuthService {
// Initialise the protobuf validator
pbVal, err := protovalidate.New()
if err != nil {
log.Panic().Err(err).Msg("failed to initialise protobuf validator")
}
svc := &AuthService{
cfg: cfg,
store: store,
pbVal: pbVal,
}
return svc
}
func (svc AuthService) ServiceInfo(ctx context.Context, req *commonpb.ServiceInfoRequest) (*commonpb.ServiceInfoResponse, error) {
return &commonpb.ServiceInfoResponse{
Name: "auth",
Source: "https://github.com/hexolan/stocklet",
SourceLicense: "AGPL-3.0",
}, nil
}
func (svc AuthService) LoginPassword(ctx context.Context, req *pb.LoginPasswordRequest) (*pb.LoginPasswordResponse, error) {
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// provide validation err context to user
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// Verify password
match, err := svc.store.VerifyPassword(ctx, req.UserId, req.Password)
if err != nil || match == false {
return nil, errors.WrapServiceError(errors.ErrCodeForbidden, "invalid user id or password", err)
}
// Issue token for the user
token, err := issueToken(svc.cfg, req.UserId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "error issuing token", err)
}
return &pb.LoginPasswordResponse{Detail: "Success", Data: token}, nil
}
func (svc AuthService) SetPassword(ctx context.Context, req *pb.SetPasswordRequest) (*pb.SetPasswordResponse, error) {
// If the request is through the gateway,
// then perform permission checking
gatewayRequest, gwMd := gwauth.IsGatewayRequest(ctx)
if gatewayRequest {
log.Info().Msg("is a gateway request")
// Ensure user is authenticated
claims, err := gwauth.GetGatewayUser(gwMd)
if err != nil {
return nil, err
}
// Only allow changing of own password
req.UserId = claims.Subject
}
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// provide validation err context to user
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// Set the password
err := svc.store.SetPassword(ctx, req.UserId, req.Password)
if err != nil {
return nil, err
}
return &pb.SetPasswordResponse{Detail: "Successfully updated password"}, nil
}
func (svc AuthService) ProcessUserDeletedEvent(ctx context.Context, req *eventpb.UserDeletedEvent) (*emptypb.Empty, error) {
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// provide validation err context to user
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
err := svc.store.DeleteAuthMethods(ctx, req.UserId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to process event", err)
}
return &emptypb.Empty{}, nil
}
// Provide the JWK ECDSA public key as part of a JSON Web Key set.
// This method is called by the API gateway for usage when validating inbound JWT tokens.
func (svc AuthService) GetJwks(ctx context.Context, req *pb.GetJwksRequest) (*pb.GetJwksResponse, error) {
return &pb.GetJwksResponse{Keys: []*pb.PublicEcJWK{svc.cfg.ServiceOpts.PublicJwk}}, nil
}

156
internal/svc/auth/config.go Normal file
View File

@@ -0,0 +1,156 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/encoding/protojson"
"github.com/hexolan/stocklet/internal/pkg/config"
"github.com/hexolan/stocklet/internal/pkg/errors"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/auth/v1"
)
// Auth Service Configuration
type ServiceConfig struct {
// Core configuration
Shared config.SharedConfig
ServiceOpts ServiceConfigOpts
// Dynamically loaded configuration
Postgres config.PostgresConfig
Kafka config.KafkaConfig
}
// load the service configuration
func NewServiceConfig() (*ServiceConfig, error) {
cfg := ServiceConfig{}
// load the shared config options
if err := cfg.Shared.Load(); err != nil {
return nil, err
}
// load the service config opts
if err := cfg.ServiceOpts.Load(); err != nil {
return nil, err
}
return &cfg, nil
}
// Service specific config options
type ServiceConfigOpts struct {
// Env Var: "AUTH_PRIVATE_KEY"
// to be provided in base64 format
PrivateKey *ecdsa.PrivateKey
// Generated from PrivateKey
PublicJwk *pb.PublicEcJWK
}
// Load the ServiceConfigOpts
//
// PrivateKey is loaded and decoded from the base64
// encoded PEM file exposed in the 'AUTH_PRIVATE_KEY'
// environment variable.
func (opts *ServiceConfigOpts) Load() error {
// load the private key
if err := opts.loadPrivateKey(); err != nil {
return err
}
// prepare the JWK public key
opts.PublicJwk = preparePublicJwk(opts.PrivateKey)
return nil
}
// Load the ECDSA private key.
//
// Used for signing JWT tokens.
// The public key is also served in JWK format, from this service,
// for use when validating the tokens at the API ingress.
func (opts *ServiceConfigOpts) loadPrivateKey() error {
// PEM private key file exposed as an environment variable encoded in base64
opt, err := config.RequireFromEnv("AUTH_PRIVATE_KEY")
if err != nil {
return err
}
// Decode from base64
pkBytes, err := base64.StdEncoding.DecodeString(opt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "the provided 'AUTH_PRIVATE_KEY' is not valid base64", err)
}
// Decode the PEM key
pkBlock, _ := pem.Decode(pkBytes)
if pkBlock == nil {
return errors.NewServiceError(errors.ErrCodeService, "the provided 'AUTH_PRIVATE_KEY' is not valid PEM format")
}
// Parse the block to a ecdsa.PrivateKey object
privKey, err := x509.ParseECPrivateKey(pkBlock.Bytes)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to parse the provided 'AUTH_PRIVATE_KEY' to an EC private key", err)
}
opts.PrivateKey = privKey
return nil
}
// Converts the ECDSA key to a public JWK.
func preparePublicJwk(privateKey *ecdsa.PrivateKey) *pb.PublicEcJWK {
// Assemble the public JWK
jwk, err := jwk.FromRaw(privateKey.PublicKey)
if err != nil {
log.Panic().Err(err).Msg("something went wrong parsing public key from private key")
}
// denote use for signatures
jwk.Set("use", "sig")
// envoy includes support for ES256, ES384 and ES512
alg := fmt.Sprintf("ES%v", privateKey.Curve.Params().BitSize)
if alg != "ES256" && alg != "ES384" && alg != "ES512" {
log.Panic().Err(err).Msg("unsupported bitsize for private key")
}
jwk.Set("alg", alg)
// Convert the JWK to JSON
jwkBytes, err := json.Marshal(jwk)
if err != nil {
log.Panic().Err(err).Msg("something went wrong preparing the public JWK (json marshal)")
}
// Unmarshal the JSON to Protobuf format
publicJwkPB := pb.PublicEcJWK{}
err = protojson.Unmarshal(jwkBytes, &publicJwkPB)
if err != nil {
log.Panic().Err(err).Msg("something went wrong preparing the public JWK (protonjson unmarshal)")
}
return &publicJwkPB
}

View File

@@ -0,0 +1,109 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"github.com/rs/zerolog/log"
"github.com/twmb/franz-go/pkg/kgo"
"google.golang.org/protobuf/proto"
"github.com/hexolan/stocklet/internal/pkg/messaging"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/auth/v1"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
"github.com/hexolan/stocklet/internal/svc/auth"
)
type kafkaController struct {
cl *kgo.Client
svc pb.AuthServiceServer
ctx context.Context
ctxCancel context.CancelFunc
}
func NewKafkaController(cl *kgo.Client) auth.ConsumerController {
// Create a cancellable context for the consumer
ctx, ctxCancel := context.WithCancel(context.Background())
// Ensure the required Kafka topics exist
err := messaging.EnsureKafkaTopics(
cl,
messaging.User_State_Deleted_Topic,
)
if err != nil {
log.Warn().Err(err).Msg("kafka: raised attempting to ensure svc topics")
}
// Add the consumption topics
cl.AddConsumeTopics(
messaging.User_State_Deleted_Topic,
)
return &kafkaController{cl: cl, ctx: ctx, ctxCancel: ctxCancel}
}
func (c *kafkaController) Attach(svc pb.AuthServiceServer) {
c.svc = svc
}
func (c *kafkaController) Start() {
if c.svc == nil {
log.Panic().Msg("consumer: no service interface attached")
}
for {
fetches := c.cl.PollFetches(c.ctx)
if errs := fetches.Errors(); len(errs) > 0 {
log.Panic().Any("kafka-errs", errs).Msg("consumer: unrecoverable kafka errors")
}
fetches.EachTopic(func(ft kgo.FetchTopic) {
switch ft.Topic {
case messaging.User_State_Deleted_Topic:
c.consumeUserDeletedEventTopic(ft)
default:
log.Warn().Str("topic", ft.Topic).Msg("consumer: recieved records from unexpected topic")
}
})
}
}
func (c *kafkaController) Stop() {
// Cancel the consumer context
c.ctxCancel()
}
func (c *kafkaController) consumeUserDeletedEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.UserDeletedEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessUserDeletedEvent(ctx, &event)
})
}

View File

@@ -0,0 +1,97 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hexolan/stocklet/internal/pkg/errors"
"github.com/hexolan/stocklet/internal/svc/auth"
)
type postgresController struct {
cl *pgxpool.Pool
}
func NewPostgresController(cl *pgxpool.Pool) auth.StorageController {
return postgresController{cl: cl}
}
func (c postgresController) getPasswordAuthMethod(ctx context.Context, userId string) (string, error) {
var hashedPassword string
err := c.cl.QueryRow(ctx, "SELECT hashed_password FROM auth_methods WHERE user_id=$1", userId).Scan(&hashedPassword)
if err != nil {
return "", errors.WrapServiceError(errors.ErrCodeNotFound, "unknown user id", err)
}
return hashedPassword, nil
}
func (c postgresController) SetPassword(ctx context.Context, userId string, password string) error {
hashedPassword, err := auth.HashPassword(password)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeInvalidArgument, "unable to hash password", err)
}
// Check if auth method already exists
var statement string
_, err = c.getPasswordAuthMethod(ctx, userId)
if err != nil {
// Auth method does not exist
statement = "INSERT INTO auth_methods (user_id, hashed_password) VALUES ($1, $2)"
} else {
// Auth method already exists
statement = "UPDATE auth_methods SET hashed_password=$2 WHERE user_id=$1"
}
// Execute statement
result, err := c.cl.Exec(ctx, statement, userId, hashedPassword)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to set password", err)
}
// Ensure a row was affected
if result.RowsAffected() != 1 {
return errors.WrapServiceError(errors.ErrCodeExtService, "auth methods unaffected", err)
}
return nil
}
func (c postgresController) VerifyPassword(ctx context.Context, userId string, password string) (bool, error) {
hashedPassword, err := c.getPasswordAuthMethod(ctx, userId)
if err != nil {
return false, err
}
match := auth.CompareHashAndPassword(password, hashedPassword)
if !match {
return false, errors.WrapServiceError(errors.ErrCodeForbidden, "invalid user id or password", err)
}
return true, nil
}
func (c postgresController) DeleteAuthMethods(ctx context.Context, userId string) error {
_, err := c.cl.Exec(ctx, "DELETE FROM auth_methods WHERE user_id=$1", userId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to delete", err)
}
return nil
}

View File

@@ -0,0 +1,40 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"golang.org/x/crypto/bcrypt"
)
func HashPassword(password string) (string, error) {
// bcrypt has a 72 byte limitation on password length
// passwords are already validated to ensure no greater than 64 chars
hashedPasswordB, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashedPasswordB), nil
}
func CompareHashAndPassword(password string, hashedPassword string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
if err != nil {
return false
}
return true
}

56
internal/svc/auth/jwt.go Normal file
View File

@@ -0,0 +1,56 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"time"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/hexolan/stocklet/internal/pkg/errors"
"github.com/hexolan/stocklet/internal/pkg/gwauth"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/auth/v1"
)
// Issues a JWT token
func issueToken(cfg *ServiceConfig, sub string) (*pb.AuthToken, error) {
const expiryTime = 86400 // 1 day
// Set token claims
token := jwt.New()
claims := &gwauth.JWTClaims{
Subject: sub,
IssuedAt: time.Now().Unix(),
Expiry: time.Now().Unix() + expiryTime,
}
token.Set("sub", claims.Subject)
token.Set("iat", claims.IssuedAt)
token.Set("exp", claims.Expiry)
// Sign token
accessToken, err := jwt.Sign(token, jwt.WithKey(jwa.KeyAlgorithmFrom(cfg.ServiceOpts.PrivateKey), cfg.ServiceOpts.PrivateKey))
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to sign JWT token", err)
}
return &pb.AuthToken{
TokenType: "Bearer",
AccessToken: string(accessToken),
ExpiresIn: expiryTime,
}, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"context"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/rs/zerolog/log"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/order/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/order"
)
func PrepareGateway(cfg *order.ServiceConfig) *runtime.ServeMux {
mux, clientOpts := serve.NewGatewayServeBase(&cfg.Shared)
ctx := context.Background()
err := pb.RegisterOrderServiceHandlerFromEndpoint(ctx, mux, serve.GetAddrToGrpc("localhost"), clientOpts)
if err != nil {
log.Panic().Err(err).Msg("failed to register endpoint for gateway")
}
return mux
}

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"google.golang.org/grpc"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/order/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/order"
)
func PrepareGrpc(cfg *order.ServiceConfig, svc *order.OrderService) *grpc.Server {
svr := serve.NewGrpcServeBase(&cfg.Shared)
pb.RegisterOrderServiceServer(svr, svc)
return svr
}

View File

@@ -0,0 +1,42 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package order
import (
"github.com/hexolan/stocklet/internal/pkg/config"
)
// Order Service Configuration
type ServiceConfig struct {
// Core Configuration
Shared config.SharedConfig
// Dynamically loaded configuration
Postgres config.PostgresConfig
Kafka config.KafkaConfig
}
// load the base service configuration
func NewServiceConfig() (*ServiceConfig, error) {
cfg := ServiceConfig{}
// Load the core configuration
if err := cfg.Shared.Load(); err != nil {
return nil, err
}
return &cfg, nil
}

View File

@@ -0,0 +1,179 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"github.com/rs/zerolog/log"
"github.com/twmb/franz-go/pkg/kgo"
"google.golang.org/protobuf/proto"
"github.com/hexolan/stocklet/internal/pkg/messaging"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/order/v1"
"github.com/hexolan/stocklet/internal/svc/order"
)
type kafkaController struct {
cl *kgo.Client
svc pb.OrderServiceServer
ctx context.Context
ctxCancel context.CancelFunc
}
func NewKafkaController(cl *kgo.Client) order.ConsumerController {
// Create a cancellable context for the consumer
ctx, ctxCancel := context.WithCancel(context.Background())
// Ensure the required Kafka topics exist
err := messaging.EnsureKafkaTopics(
cl,
messaging.Order_State_Created_Topic,
messaging.Order_State_Pending_Topic,
messaging.Order_State_Rejected_Topic,
messaging.Order_State_Approved_Topic,
messaging.Warehouse_Reservation_Failed_Topic,
messaging.Shipping_Shipment_Allocation_Topic,
messaging.Payment_Processing_Topic,
)
if err != nil {
log.Warn().Err(err).Msg("kafka: raised attempting to ensure svc topics")
}
// Add the consumption topics
cl.AddConsumeTopics(
messaging.Product_PriceQuotation_Topic,
messaging.Warehouse_Reservation_Failed_Topic,
messaging.Shipping_Shipment_Allocation_Topic,
messaging.Payment_Processing_Topic,
)
return &kafkaController{cl: cl, ctx: ctx, ctxCancel: ctxCancel}
}
func (c *kafkaController) Attach(svc pb.OrderServiceServer) {
c.svc = svc
}
func (c *kafkaController) Start() {
if c.svc == nil {
log.Panic().Msg("consumer: no service interface attached")
}
for {
fetches := c.cl.PollFetches(c.ctx)
if errs := fetches.Errors(); len(errs) > 0 {
log.Panic().Any("kafka-errs", errs).Msg("consumer: unrecoverable kafka errors")
}
fetches.EachTopic(func(ft kgo.FetchTopic) {
switch ft.Topic {
case messaging.Product_PriceQuotation_Topic:
c.consumeProductPriceQuoteEventTopic(ft)
case messaging.Warehouse_Reservation_Failed_Topic:
c.consumeStockReservationEventTopic(ft)
case messaging.Shipping_Shipment_Allocation_Topic:
c.consumeShipmentAllocationEventTopic(ft)
case messaging.Payment_Processing_Topic:
c.consumePaymentProcessedEventTopic(ft)
default:
log.Warn().Str("topic", ft.Topic).Msg("consumer: recieved records from unexpected topic")
}
})
}
}
func (c *kafkaController) Stop() {
// Cancel the consumer context
c.ctxCancel()
}
func (c *kafkaController) consumeProductPriceQuoteEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.ProductPriceQuoteEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessProductPriceQuoteEvent(ctx, &event)
})
}
func (c *kafkaController) consumeStockReservationEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.StockReservationEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessStockReservationEvent(ctx, &event)
})
}
func (c *kafkaController) consumeShipmentAllocationEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.ShipmentAllocationEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessShipmentAllocationEvent(ctx, &event)
})
}
func (c *kafkaController) consumePaymentProcessedEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.PaymentProcessedEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessPaymentProcessedEvent(ctx, &event)
})
}

View File

@@ -0,0 +1,458 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"time"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hexolan/stocklet/internal/pkg/errors"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/order/v1"
"github.com/hexolan/stocklet/internal/svc/order"
)
const (
pgOrderBaseQuery string = "SELECT id, status, customer_id, shipping_id, transaction_id, created_at, updated_at FROM orders"
pgOrderItemsBaseQuery string = "SELECT product_id, quantity FROM order_items"
)
// The postgres controller is responsible for implementing the StorageController interface
// to store and retrieve the requested items from the Postgres database.
//
// Other controllers can be implemented to interface with different database systems.
type postgresController struct {
cl *pgxpool.Pool
}
// Creates a new postgresController that implements the StorageController interface.
func NewPostgresController(cl *pgxpool.Pool) order.StorageController {
return postgresController{cl: cl}
}
// Internal method - Validation is assumed to have taken place already
// Gets an order by its specified id from the database
func (c postgresController) GetOrder(ctx context.Context, orderId string) (*pb.Order, error) {
return c.getOrder(ctx, nil, orderId)
}
func (c postgresController) getOrder(ctx context.Context, tx *pgx.Tx, orderId string) (*pb.Order, error) {
// Query order
var row pgx.Row
if tx == nil {
row = c.cl.QueryRow(ctx, pgOrderBaseQuery+" WHERE id=$1", orderId)
} else {
row = (*tx).QueryRow(ctx, pgOrderBaseQuery+" WHERE id=$1", orderId)
}
// Scan row to protobuf order
order, err := scanRowToOrder(row)
if err != nil {
return nil, err
}
// Append the order items
order, err = c.appendItemsToOrderObj(ctx, tx, order)
if err != nil {
return nil, err
}
return order, nil
}
// Internal method - Inputs are assumed valid
// Create a new order in the database
func (c postgresController) CreateOrder(ctx context.Context, orderObj *pb.Order) (*pb.Order, error) {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Prepare and perform insert query
newOrder := pb.Order{
Items: orderObj.Items,
Status: orderObj.Status,
CustomerId: orderObj.CustomerId,
CreatedAt: time.Now().Unix(),
}
err = tx.QueryRow(
ctx,
"INSERT INTO orders (status, customer_id) VALUES ($1, $2) RETURNING id",
newOrder.Status,
newOrder.CustomerId,
).Scan(&newOrder.Id)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to create order", err)
}
// Create records for any order items
err = c.createOrderItems(ctx, tx, newOrder.Id, newOrder.Items)
if err != nil {
// The deffered rollback will be called (so the transaction will not be commited)
return nil, err
}
// Prepare a created event.
//
// Then add the event to the outbox table with the transaction
// to ensure that the event will be dispatched if
// the transaction succeeds.
evt, evtTopic, err := order.PrepareOrderCreatedEvent(&newOrder)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to create order event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", newOrder.Id, evtTopic, evt)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert order event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return &newOrder, nil
}
// Get all orders related to a specified customer.
// TODO: implement pagination
func (c postgresController) GetCustomerOrders(ctx context.Context, customerId string) ([]*pb.Order, error) {
rows, err := c.cl.Query(ctx, pgOrderBaseQuery+" WHERE customer_id=$1 LIMIT 10", customerId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "query error whilst fetching customer orders", err)
}
orders := []*pb.Order{}
for rows.Next() {
orderObj, err := scanRowToOrder(rows)
if err != nil {
return nil, err
}
// Append the order's items
orderObj, err = c.appendItemsToOrderObj(ctx, nil, orderObj)
if err != nil {
return nil, err
}
orders = append(orders, orderObj)
}
if rows.Err() != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "error whilst scanning order rows", rows.Err())
}
return orders, nil
}
// Set order status to approved
// Dispatch OrderApprovedEvent
func (c postgresController) ApproveOrder(ctx context.Context, orderId string, transactionId string) (*pb.Order, error) {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Execute update query
_, err = tx.Exec(
ctx,
"UPDATE orders SET status = $1, transaction_id = $2 WHERE id = $3",
pb.OrderStatus_ORDER_STATUS_APPROVED,
transactionId,
orderId,
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to approve order", err)
}
orderObj, err := c.getOrder(ctx, &tx, orderId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to approve order", err)
}
// Then add the event to the outbox table with the transaction.
evt, evtTopic, err := order.PrepareOrderApprovedEvent(orderObj)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", orderObj.Id, evtTopic, evt)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return orderObj, nil
}
// Set order status to processing
// Dispatch OrderProcessingEvent
func (c postgresController) ProcessOrder(ctx context.Context, orderId string, itemsPrice float32) (*pb.Order, error) {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Execute update query
_, err = tx.Exec(
ctx,
"UPDATE orders SET status = $1, items_price = $2, total_price = $3 WHERE id = $4",
pb.OrderStatus_ORDER_STATUS_PROCESSING,
itemsPrice,
itemsPrice,
orderId,
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update order", err)
}
orderObj, err := c.getOrder(ctx, &tx, orderId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update order", err)
}
// Then add the event to the outbox table with the transaction.
// todo: fix name discrepency (mixed up processing and pending in my wording)
evt, evtTopic, err := order.PrepareOrderPendingEvent(orderObj)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", orderObj.Id, evtTopic, evt)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return orderObj, nil
}
// Set order status to rejected (from processing)
// Dispatch OrderRejectedEvent
func (c postgresController) RejectOrder(ctx context.Context, orderId string) (*pb.Order, error) {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Execute update query
_, err = tx.Exec(
ctx,
"UPDATE orders SET status = $1 WHERE id = $2",
pb.OrderStatus_ORDER_STATUS_REJECTED,
orderId,
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to approve order", err)
}
orderObj, err := c.getOrder(ctx, &tx, orderId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to approve order", err)
}
// Then add the event to the outbox table with the transaction.
evt, evtTopic, err := order.PrepareOrderRejectedEvent(orderObj)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", orderObj.Id, evtTopic, evt)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return orderObj, nil
}
// Append shipment id to order
func (c postgresController) SetOrderShipmentId(ctx context.Context, orderId string, shippingId string) error {
// Execute update query
_, err := c.cl.Exec(
ctx,
"UPDATE orders SET shipment_id = $1 WHERE id = $2",
shippingId,
orderId,
)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to approve order", err)
}
return nil
}
// Build and exec an insert statement for a map of order items
func (c postgresController) createOrderItems(ctx context.Context, tx pgx.Tx, orderId string, items map[string]int32) error {
// check there are items to add
if len(items) > 1 {
vals := [][]interface{}{}
for productId, quantity := range items {
vals = append(
vals,
goqu.Vals{orderId, productId, quantity},
)
}
statement, args, err := goqu.Dialect("postgres").From(
"order_items",
).Insert().Cols(
"order_id",
"product_id",
"quantity",
).Vals(
vals...,
).Prepared(
true,
).ToSQL()
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to build SQL statement", err)
}
// Execute the statement on the transaction
_, err = tx.Exec(ctx, statement, args...)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to add items to order", err)
}
}
return nil
}
func (c postgresController) getOrderItems(ctx context.Context, tx *pgx.Tx, orderId string) (*map[string]int32, error) {
// Determine if transaction is being used.
var rows pgx.Rows
var err error
if tx == nil {
rows, err = c.cl.Query(ctx, pgOrderItemsBaseQuery+" WHERE order_id=$1", orderId)
} else {
rows, err = (*tx).Query(ctx, pgOrderItemsBaseQuery+" WHERE order_id=$1", orderId)
}
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "query error whilst fetching order items", err)
}
items := make(map[string]int32)
for rows.Next() {
var (
itemId string
quantity int32
)
err := rows.Scan(
&itemId,
&quantity,
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to scan an order item", err)
}
items[itemId] = quantity
}
if rows.Err() != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "error whilst scanning order item rows", rows.Err())
}
return &items, nil
}
// Appends order items to an order object.
func (c postgresController) appendItemsToOrderObj(ctx context.Context, tx *pgx.Tx, orderObj *pb.Order) (*pb.Order, error) {
// Load the order items
orderItems, err := c.getOrderItems(ctx, tx, orderObj.Id)
if err != nil {
return nil, err
}
// Add the order items to the order protobuf
orderObj.Items = *orderItems
// Return the order
return orderObj, nil
}
// Scan a postgres row to a protobuf order object.
func scanRowToOrder(row pgx.Row) (*pb.Order, error) {
var order pb.Order
// Temporary variables that require conversion
var tmpCreatedAt pgtype.Timestamp
var tmpUpdatedAt pgtype.Timestamp
err := row.Scan(
&order.Id,
&order.Status,
&order.CustomerId,
&order.ShippingId,
&order.TransactionId,
&tmpCreatedAt,
&tmpUpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, errors.WrapServiceError(errors.ErrCodeNotFound, "order not found", err)
} else {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "something went wrong scanning order", err)
}
}
// Convert the temporary variables
//
// This includes converting postgres timestamps to unix format
if tmpCreatedAt.Valid {
order.CreatedAt = tmpCreatedAt.Time.Unix()
} else {
return nil, errors.NewServiceError(errors.ErrCodeUnknown, "failed to convert order (created_at) timestamp")
}
if tmpUpdatedAt.Valid {
unixUpdated := tmpUpdatedAt.Time.Unix()
order.UpdatedAt = &unixUpdated
}
return &order, nil
}

View File

@@ -0,0 +1,74 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package order
import (
"github.com/hexolan/stocklet/internal/pkg/messaging"
eventspb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/order/v1"
)
func PrepareOrderCreatedEvent(order *pb.Order) ([]byte, string, error) {
topic := messaging.Order_State_Created_Topic
event := &eventspb.OrderCreatedEvent{
Revision: 1,
OrderId: order.Id,
CustomerId: order.CustomerId,
ItemQuantities: order.Items,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareOrderPendingEvent(order *pb.Order) ([]byte, string, error) {
topic := messaging.Order_State_Pending_Topic
event := &eventspb.OrderPendingEvent{
Revision: 1,
OrderId: order.Id,
CustomerId: order.CustomerId,
ItemQuantities: order.Items,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareOrderRejectedEvent(order *pb.Order) ([]byte, string, error) {
topic := messaging.Order_State_Rejected_Topic
event := &eventspb.OrderRejectedEvent{
Revision: 1,
OrderId: order.Id,
TransactionId: order.TransactionId,
ShippingId: order.ShippingId,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareOrderApprovedEvent(order *pb.Order) ([]byte, string, error) {
topic := messaging.Order_State_Approved_Topic
event := &eventspb.OrderApprovedEvent{
Revision: 1,
OrderId: order.Id,
TransactionId: order.GetTransactionId(),
ShippingId: order.GetShippingId(),
}
return messaging.MarshalEvent(event, topic)
}

229
internal/svc/order/order.go Normal file
View File

@@ -0,0 +1,229 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package order
import (
"context"
"github.com/bufbuild/protovalidate-go"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/hexolan/stocklet/internal/pkg/errors"
"github.com/hexolan/stocklet/internal/pkg/gwauth"
"github.com/hexolan/stocklet/internal/pkg/messaging"
commonpb "github.com/hexolan/stocklet/internal/pkg/protogen/common/v1"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/order/v1"
)
// Interface for the service
type OrderService struct {
pb.UnimplementedOrderServiceServer
store StorageController
pbVal *protovalidate.Validator
}
// Interface for database methods
// Flexibility for implementing seperate controllers for different databases (e.g. Postgres, MongoDB, etc)
type StorageController interface {
GetOrder(ctx context.Context, orderId string) (*pb.Order, error)
GetCustomerOrders(ctx context.Context, customerId string) ([]*pb.Order, error)
CreateOrder(ctx context.Context, order *pb.Order) (*pb.Order, error)
ApproveOrder(ctx context.Context, orderId string, transactionId string) (*pb.Order, error)
ProcessOrder(ctx context.Context, orderId string, itemsPrice float32) (*pb.Order, error)
RejectOrder(ctx context.Context, orderId string) (*pb.Order, error)
SetOrderShipmentId(ctx context.Context, orderId string, shippingId string) error
}
// Interface for event consumption
// Flexibility for seperate controllers for different messaging systems (e.g. Kafka, NATS, etc)
type ConsumerController interface {
messaging.ConsumerController
Attach(svc pb.OrderServiceServer)
}
// Create the order service
func NewOrderService(cfg *ServiceConfig, store StorageController) *OrderService {
// Initialise the protobuf validator
pbVal, err := protovalidate.New()
if err != nil {
log.Panic().Err(err).Msg("failed to initialise protobuf validator")
}
// Initialise the service
return &OrderService{
store: store,
pbVal: pbVal,
}
}
func (svc OrderService) ServiceInfo(ctx context.Context, req *commonpb.ServiceInfoRequest) (*commonpb.ServiceInfoResponse, error) {
return &commonpb.ServiceInfoResponse{
Name: "order",
Source: "https://github.com/hexolan/stocklet",
SourceLicense: "AGPL-3.0",
}, nil
}
func (svc OrderService) ViewOrder(ctx context.Context, req *pb.ViewOrderRequest) (*pb.ViewOrderResponse, error) {
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// Provide the validation error to the user.
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// Get the order from the DB
order, err := svc.store.GetOrder(ctx, req.OrderId)
if err != nil {
return nil, err
}
return &pb.ViewOrderResponse{Order: order}, nil
}
func (svc OrderService) ViewOrders(ctx context.Context, req *pb.ViewOrdersRequest) (*pb.ViewOrdersResponse, error) {
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// provide validation err context to user
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// Get the orders from the storage controller
orders, err := svc.store.GetCustomerOrders(ctx, req.CustomerId)
if err != nil {
return nil, err
}
return &pb.ViewOrdersResponse{Orders: orders}, nil
}
func (svc OrderService) PlaceOrder(ctx context.Context, req *pb.PlaceOrderRequest) (*pb.PlaceOrderResponse, error) {
// If the request is through the gateway, then substitute req.CustomerId for current user
gatewayRequest, gwMd := gwauth.IsGatewayRequest(ctx)
if gatewayRequest {
// ensure user is authenticated
claims, err := gwauth.GetGatewayUser(gwMd)
if err != nil {
return nil, err
}
req.CustomerId = claims.Subject
}
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// provide validation err context to user
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// Create the order.
//
// This will initiate a SAGA process involving
// all the services required to create the order
order, err := svc.store.CreateOrder(
ctx,
&pb.Order{
Status: pb.OrderStatus_ORDER_STATUS_PROCESSING,
Items: req.Cart,
CustomerId: req.CustomerId,
},
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeUnknown, "failed to create order", err)
}
// Return the pending order
return &pb.PlaceOrderResponse{Order: order}, nil
}
func (svc OrderService) ProcessProductPriceQuoteEvent(ctx context.Context, req *eventpb.ProductPriceQuoteEvent) (*emptypb.Empty, error) {
if req.Type == eventpb.ProductPriceQuoteEvent_TYPE_AVALIABLE {
// Set order status to processing (from pending)
// Dispatch OrderProcessingEvent
_, err := svc.store.ProcessOrder(ctx, req.OrderId, req.TotalPrice)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update in response to event", err)
}
} else if req.Type == eventpb.ProductPriceQuoteEvent_TYPE_UNAVALIABLE {
// Set order status to rejected (from pending)
// Dispatch OrderRejectedEvent
_, err := svc.store.RejectOrder(ctx, req.OrderId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update in response to event", err)
}
} else {
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid event type")
}
return &emptypb.Empty{}, nil
}
func (svc OrderService) ProcessStockReservationEvent(ctx context.Context, req *eventpb.StockReservationEvent) (*emptypb.Empty, error) {
if req.Type == eventpb.StockReservationEvent_TYPE_INSUFFICIENT_STOCK {
// Set order status to rejected (from processing)
// Dispatch OrderRejectedEvent
_, err := svc.store.RejectOrder(ctx, req.OrderId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update in response to event", err)
}
}
return &emptypb.Empty{}, nil
}
func (svc OrderService) ProcessShipmentAllocationEvent(ctx context.Context, req *eventpb.ShipmentAllocationEvent) (*emptypb.Empty, error) {
if req.Type == eventpb.ShipmentAllocationEvent_TYPE_FAILED {
// Set order status to rejected (from processing)
// Dispatch OrderRejectedEvent
_, err := svc.store.RejectOrder(ctx, req.OrderId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update in response to event", err)
}
} else if req.Type == eventpb.ShipmentAllocationEvent_TYPE_ALLOCATED {
// Append shipment id to order
err := svc.store.SetOrderShipmentId(ctx, req.OrderId, req.ShipmentId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update in response to event", err)
}
}
return &emptypb.Empty{}, nil
}
func (svc OrderService) ProcessPaymentProcessedEvent(ctx context.Context, req *eventpb.PaymentProcessedEvent) (*emptypb.Empty, error) {
if req.Type == eventpb.PaymentProcessedEvent_TYPE_SUCCESS {
// Set order status to approved (from processing)
// Dispatch OrderApprovedEvent
_, err := svc.store.ApproveOrder(ctx, req.OrderId, *req.TransactionId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update in response to event", err)
}
} else if req.Type == eventpb.PaymentProcessedEvent_TYPE_FAILED {
// Set order status to rejected (from processing)
// Dispatch OrderRejectedEvent
_, err := svc.store.RejectOrder(ctx, req.OrderId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update in response to event", err)
}
}
return &emptypb.Empty{}, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"context"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/rs/zerolog/log"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/payment/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/payment"
)
func PrepareGateway(cfg *payment.ServiceConfig) *runtime.ServeMux {
mux, clientOpts := serve.NewGatewayServeBase(&cfg.Shared)
ctx := context.Background()
err := pb.RegisterPaymentServiceHandlerFromEndpoint(ctx, mux, serve.GetAddrToGrpc("localhost"), clientOpts)
if err != nil {
log.Panic().Err(err).Msg("failed to register endpoint for gateway")
}
return mux
}

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"google.golang.org/grpc"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/payment/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/payment"
)
func PrepareGrpc(cfg *payment.ServiceConfig, svc *payment.PaymentService) *grpc.Server {
svr := serve.NewGrpcServeBase(&cfg.Shared)
pb.RegisterPaymentServiceServer(svr, svc)
return svr
}

View File

@@ -0,0 +1,42 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package payment
import (
"github.com/hexolan/stocklet/internal/pkg/config"
)
// Order Service Configuration
type ServiceConfig struct {
// Core Configuration
Shared config.SharedConfig
// Dynamically loaded configuration
Postgres config.PostgresConfig
Kafka config.KafkaConfig
}
// load the base service configuration
func NewServiceConfig() (*ServiceConfig, error) {
cfg := ServiceConfig{}
// Load the core configuration
if err := cfg.Shared.Load(); err != nil {
return nil, err
}
return &cfg, nil
}

View File

@@ -0,0 +1,140 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"github.com/rs/zerolog/log"
"github.com/twmb/franz-go/pkg/kgo"
"google.golang.org/protobuf/proto"
"github.com/hexolan/stocklet/internal/pkg/messaging"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/payment/v1"
"github.com/hexolan/stocklet/internal/svc/payment"
)
type kafkaController struct {
cl *kgo.Client
svc pb.PaymentServiceServer
ctx context.Context
ctxCancel context.CancelFunc
}
func NewKafkaController(cl *kgo.Client) payment.ConsumerController {
// Create a cancellable context for the consumer
ctx, ctxCancel := context.WithCancel(context.Background())
// Ensure the required Kafka topics exist
err := messaging.EnsureKafkaTopics(
cl,
messaging.Payment_Balance_Created_Topic,
messaging.Payment_Balance_Credited_Topic,
messaging.Payment_Balance_Debited_Topic,
messaging.Payment_Balance_Closed_Topic,
messaging.Payment_Transaction_Created_Topic,
messaging.Payment_Transaction_Reversed_Topic,
messaging.Payment_Processing_Topic,
messaging.User_State_Created_Topic,
messaging.Shipping_Shipment_Allocation_Topic,
)
if err != nil {
log.Warn().Err(err).Msg("kafka: raised attempting to ensure svc topics")
}
// Add the consumption topics
cl.AddConsumeTopics(
messaging.User_State_Created_Topic,
messaging.Shipping_Shipment_Allocation_Topic,
)
return &kafkaController{cl: cl, ctx: ctx, ctxCancel: ctxCancel}
}
func (c *kafkaController) Attach(svc pb.PaymentServiceServer) {
c.svc = svc
}
func (c *kafkaController) Start() {
if c.svc == nil {
log.Panic().Msg("consumer: no service interface attached")
}
for {
fetches := c.cl.PollFetches(c.ctx)
if errs := fetches.Errors(); len(errs) > 0 {
log.Panic().Any("kafka-errs", errs).Msg("consumer: unrecoverable kafka errors")
}
fetches.EachTopic(func(ft kgo.FetchTopic) {
switch ft.Topic {
case messaging.User_State_Created_Topic:
c.consumeUserCreatedEventTopic(ft)
case messaging.Shipping_Shipment_Allocation_Topic:
c.consumeShipmentAllocationEventTopic(ft)
default:
log.Warn().Str("topic", ft.Topic).Msg("consumer: recieved records from unexpected topic")
}
})
}
}
func (c *kafkaController) Stop() {
// Cancel the consumer context
c.ctxCancel()
}
func (c *kafkaController) consumeUserCreatedEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.UserCreatedEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessUserCreatedEvent(ctx, &event)
})
}
func (c *kafkaController) consumeShipmentAllocationEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.ShipmentAllocationEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessShipmentAllocationEvent(ctx, &event)
})
}

View File

@@ -0,0 +1,458 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hexolan/stocklet/internal/pkg/errors"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/payment/v1"
"github.com/hexolan/stocklet/internal/svc/payment"
)
const (
pgTransactionBaseQuery string = "SELECT id, order_id, customer_id, amount, reversed_at, processed_at FROM transactions"
pgCustomerBalanceBaseQuery string = "SELECT customer_id, balance FROM customer_balances"
)
type postgresController struct {
cl *pgxpool.Pool
}
func NewPostgresController(cl *pgxpool.Pool) payment.StorageController {
return postgresController{cl: cl}
}
func (c postgresController) GetBalance(ctx context.Context, customerId string) (*pb.CustomerBalance, error) {
return c.getBalance(ctx, nil, customerId)
}
func (c postgresController) getBalance(ctx context.Context, tx *pgx.Tx, customerId string) (*pb.CustomerBalance, error) {
// Determine if a db transaction is being used
var row pgx.Row
const query = pgCustomerBalanceBaseQuery + " WHERE customer_id=$1"
if tx == nil {
row = c.cl.QueryRow(ctx, query, customerId)
} else {
row = (*tx).QueryRow(ctx, query, customerId)
}
// Scan row to protobuf obj
balance, err := scanRowToCustomerBalance(row)
if err != nil {
return nil, err
}
return balance, nil
}
func (c postgresController) GetTransaction(ctx context.Context, transactionId string) (*pb.Transaction, error) {
return c.getTransaction(ctx, nil, transactionId)
}
func (c postgresController) getTransaction(ctx context.Context, tx *pgx.Tx, transactionId string) (*pb.Transaction, error) {
// Determine if a db transaction is being used
var row pgx.Row
const query = pgTransactionBaseQuery + " WHERE id=$1"
if tx == nil {
row = c.cl.QueryRow(ctx, query, transactionId)
} else {
row = (*tx).QueryRow(ctx, query, transactionId)
}
// Scan row to protobuf obj
transaction, err := scanRowToTransaction(row)
if err != nil {
return nil, err
}
return transaction, nil
}
func (c postgresController) CreateBalance(ctx context.Context, customerId string) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(
ctx,
"INSERT INTO customer_balances (customer_id, balance) VALUES ($1, $2)",
customerId,
0.00,
)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to create balance", err)
}
// Add the event to the outbox table with the transaction
evt, evtTopic, err := payment.PrepareBalanceCreatedEvent(&pb.CustomerBalance{CustomerId: customerId, Balance: 0.00})
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", customerId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
func (c postgresController) CreditBalance(ctx context.Context, customerId string, amount float32) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Add to balance
_, err = tx.Exec(
ctx,
"UPDATE customer_balances SET balance = balance + $1 WHERE customer_id=$2",
amount,
customerId,
)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to update balance", err)
}
// Get updated balance
balance, err := c.getBalance(ctx, &tx, customerId)
if err != nil {
return err
}
// Add the event to the outbox table with the transaction
evt, evtTopic, err := payment.PrepareBalanceCreditedEvent(
balance.CustomerId,
amount,
balance.Balance,
)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", balance.CustomerId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
func (c postgresController) DebitBalance(ctx context.Context, customerId string, amount float32, orderId *string) (*pb.Transaction, error) {
return c.debitBalance(ctx, nil, customerId, amount, orderId)
}
func (c postgresController) debitBalance(ctx context.Context, tx *pgx.Tx, customerId string, amount float32, orderId *string) (*pb.Transaction, error) {
// Determine if a transaction has already been provided
var (
funcTx pgx.Tx
err error
)
if tx != nil {
funcTx = *tx
} else {
funcTx, err = c.cl.Begin(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer funcTx.Rollback(ctx)
}
// Subtract from balance
_, err = funcTx.Exec(
ctx,
"UPDATE customer_balances SET balance = balance - $1 WHERE customer_id=$2",
amount,
customerId,
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update balance", err)
}
// Get updated balance
balance, err := c.getBalance(ctx, &funcTx, customerId)
if err != nil {
return nil, err
}
// Ensure that the balance is not negative
if balance.Balance < 0.00 {
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "insufficient balance")
}
// Add the balance event to the outbox table with the transaction
evt, evtTopic, err := payment.PrepareBalanceDebitedEvent(
balance.CustomerId,
amount,
balance.Balance,
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = funcTx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", balance.CustomerId, evtTopic, evt)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Create a payment transaction record
transaction, err := c.createTransaction(ctx, &funcTx, orderId, customerId, amount)
if err != nil {
return nil, err
}
// Commit the transaction (if created in this func)
if tx == nil {
err = funcTx.Commit(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
}
return transaction, nil
}
func (c postgresController) CloseBalance(ctx context.Context, customerId string) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Get current balance
balance, err := c.getBalance(ctx, &tx, customerId)
if err != nil {
return err
}
// Delete balance
_, err = tx.Exec(ctx, "DELETE FROM customer_balances WHERE customer_id=$1", customerId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to delete balance", err)
}
// Add the event to the outbox table with the transaction
evt, evtTopic, err := payment.PrepareBalanceClosedEvent(balance)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", balance.CustomerId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
func (c postgresController) PaymentForOrder(ctx context.Context, orderId string, customerId string, amount float32) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Attempt to debit balance for the order
transaction, err := c.debitBalance(ctx, &tx, customerId, amount, &orderId)
if err != nil {
// check that error is not a result of insufficient balance
// - or the customer not having a balance
errText := err.Error()
if errText != "insufficient balance" && !strings.HasPrefix(errText, "failed to update balance") {
return err
}
}
// Prepare response event
var (
evt []byte
evtTopic string
)
if transaction != nil {
// Succesful
evt, evtTopic, err = payment.PreparePaymentProcessedEvent_Success(transaction)
} else {
// Failure
// - result of insufficient/non-existent balance
evt, evtTopic, err = payment.PreparePaymentProcessedEvent_Failure(orderId, customerId, amount)
}
// Ensure the event was prepared succesfully
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
// Add the event to the outbox table
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", orderId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
func (c postgresController) createTransaction(ctx context.Context, tx *pgx.Tx, orderId *string, customerId string, amount float32) (*pb.Transaction, error) {
// Determine if a transaction has already been provided
var (
funcTx pgx.Tx
err error
)
if tx != nil {
funcTx = *tx
} else {
funcTx, err = c.cl.Begin(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer funcTx.Rollback(ctx)
}
// Insert the transaction
var transactionId string
err = funcTx.QueryRow(
ctx,
"INSERT INTO transactions (order_id, customer_id, amount) VALUES ($1, $2, $3) RETURNING id",
orderId,
customerId,
amount,
).Scan(&transactionId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert transaction", err)
}
// Get the transaction obj
transaction, err := c.getTransaction(ctx, &funcTx, transactionId)
if err != nil {
return nil, err
}
// Add the event to the outbox table with the transaction
evt, evtTopic, err := payment.PrepareTransactionLoggedEvent(transaction)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = funcTx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", transaction.Id, evtTopic, evt)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction (if created in this func)
if tx == nil {
err = funcTx.Commit(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
}
return transaction, nil
}
// Scan a postgres row to a protobuf object
func scanRowToTransaction(row pgx.Row) (*pb.Transaction, error) {
var transaction pb.Transaction
// Temporary variables that require conversion
var tmpProcessedAt pgtype.Timestamp
var tmpReversedAt pgtype.Timestamp
err := row.Scan(
&transaction.Id,
&transaction.OrderId,
&transaction.CustomerId,
&transaction.Amount,
&tmpReversedAt,
&tmpProcessedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, errors.WrapServiceError(errors.ErrCodeNotFound, "transaction not found", err)
} else {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to scan object from database", err)
}
}
// Convert the temporary variables
// - converting postgres timestamps to unix format
if tmpProcessedAt.Valid {
transaction.ProcessedAt = tmpProcessedAt.Time.Unix()
} else {
return nil, errors.NewServiceError(errors.ErrCodeUnknown, "failed to scan object from database (timestamp conversion)")
}
if tmpReversedAt.Valid {
unixReversed := tmpReversedAt.Time.Unix()
transaction.ReversedAt = &unixReversed
}
return &transaction, nil
}
// Scan a postgres row to a protobuf object
func scanRowToCustomerBalance(row pgx.Row) (*pb.CustomerBalance, error) {
var balance pb.CustomerBalance
err := row.Scan(
&balance.CustomerId,
&balance.Balance,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, errors.WrapServiceError(errors.ErrCodeNotFound, "balance not found", err)
} else {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to scan object from database", err)
}
}
return &balance, nil
}

View File

@@ -0,0 +1,129 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package payment
import (
"github.com/hexolan/stocklet/internal/pkg/messaging"
eventspb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/payment/v1"
)
func PrepareBalanceCreatedEvent(bal *pb.CustomerBalance) ([]byte, string, error) {
topic := messaging.Payment_Balance_Created_Topic
event := &eventspb.BalanceCreatedEvent{
Revision: 1,
CustomerId: bal.CustomerId,
Balance: bal.Balance,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareBalanceCreditedEvent(customerId string, amount float32, newBalance float32) ([]byte, string, error) {
topic := messaging.Payment_Balance_Credited_Topic
event := &eventspb.BalanceCreditedEvent{
Revision: 1,
CustomerId: customerId,
Amount: amount,
NewBalance: newBalance,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareBalanceDebitedEvent(customerId string, amount float32, newBalance float32) ([]byte, string, error) {
topic := messaging.Payment_Balance_Debited_Topic
event := &eventspb.BalanceDebitedEvent{
Revision: 1,
CustomerId: customerId,
Amount: amount,
NewBalance: newBalance,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareBalanceClosedEvent(bal *pb.CustomerBalance) ([]byte, string, error) {
topic := messaging.Payment_Balance_Closed_Topic
event := &eventspb.BalanceClosedEvent{
Revision: 1,
CustomerId: bal.CustomerId,
Balance: bal.Balance,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareTransactionLoggedEvent(transaction *pb.Transaction) ([]byte, string, error) {
topic := messaging.Payment_Transaction_Created_Topic
event := &eventspb.TransactionLoggedEvent{
Revision: 1,
TransactionId: transaction.Id,
Amount: transaction.Amount,
OrderId: transaction.OrderId,
CustomerId: transaction.CustomerId,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareTransactionReversedEvent(transaction *pb.Transaction) ([]byte, string, error) {
topic := messaging.Payment_Transaction_Reversed_Topic
event := &eventspb.TransactionReversedEvent{
Revision: 1,
TransactionId: transaction.Id,
Amount: transaction.Amount,
OrderId: transaction.OrderId,
CustomerId: transaction.CustomerId,
}
return messaging.MarshalEvent(event, topic)
}
func PreparePaymentProcessedEvent_Success(transaction *pb.Transaction) ([]byte, string, error) {
topic := messaging.Payment_Processing_Topic
event := &eventspb.PaymentProcessedEvent{
Revision: 1,
Type: eventspb.PaymentProcessedEvent_TYPE_SUCCESS,
OrderId: transaction.OrderId,
CustomerId: transaction.CustomerId,
Amount: transaction.Amount,
TransactionId: &transaction.Id,
}
return messaging.MarshalEvent(event, topic)
}
func PreparePaymentProcessedEvent_Failure(orderId string, customerId string, amount float32) ([]byte, string, error) {
topic := messaging.Payment_Processing_Topic
event := &eventspb.PaymentProcessedEvent{
Revision: 1,
Type: eventspb.PaymentProcessedEvent_TYPE_FAILED,
OrderId: orderId,
CustomerId: customerId,
Amount: amount,
}
return messaging.MarshalEvent(event, topic)
}

View File

@@ -0,0 +1,132 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package payment
import (
"context"
"github.com/bufbuild/protovalidate-go"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/hexolan/stocklet/internal/pkg/errors"
"github.com/hexolan/stocklet/internal/pkg/messaging"
commonpb "github.com/hexolan/stocklet/internal/pkg/protogen/common/v1"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/payment/v1"
)
// Interface for the service
type PaymentService struct {
pb.UnimplementedPaymentServiceServer
store StorageController
pbVal *protovalidate.Validator
}
// Interface for database methods
// Flexibility for implementing seperate controllers for different databases (e.g. Postgres, MongoDB, etc)
type StorageController interface {
GetBalance(ctx context.Context, customerId string) (*pb.CustomerBalance, error)
GetTransaction(ctx context.Context, transactionId string) (*pb.Transaction, error)
CreateBalance(ctx context.Context, customerId string) error
CreditBalance(ctx context.Context, customerId string, amount float32) error
DebitBalance(ctx context.Context, customerId string, amount float32, orderId *string) (*pb.Transaction, error)
CloseBalance(ctx context.Context, customerId string) error
PaymentForOrder(ctx context.Context, orderId string, customerId string, amount float32) error
}
// Interface for event consumption
// Flexibility for seperate controllers for different messaging systems (e.g. Kafka, NATS, etc)
type ConsumerController interface {
messaging.ConsumerController
Attach(svc pb.PaymentServiceServer)
}
// Create the payment service
func NewPaymentService(cfg *ServiceConfig, store StorageController) *PaymentService {
// Initialise the protobuf validator
pbVal, err := protovalidate.New()
if err != nil {
log.Panic().Err(err).Msg("failed to initialise protobuf validator")
}
// Initialise the service
return &PaymentService{
store: store,
pbVal: pbVal,
}
}
func (svc PaymentService) ServiceInfo(ctx context.Context, req *commonpb.ServiceInfoRequest) (*commonpb.ServiceInfoResponse, error) {
return &commonpb.ServiceInfoResponse{
Name: "payment",
Source: "https://github.com/hexolan/stocklet",
SourceLicense: "AGPL-3.0",
}, nil
}
func (svc PaymentService) ViewTransaction(ctx context.Context, req *pb.ViewTransactionRequest) (*pb.ViewTransactionResponse, error) {
// Attempt to get the transaction from the db
transaction, err := svc.store.GetTransaction(ctx, req.TransactionId)
if err != nil {
return nil, err
}
return &pb.ViewTransactionResponse{Transaction: transaction}, nil
}
func (svc PaymentService) ViewBalance(ctx context.Context, req *pb.ViewBalanceRequest) (*pb.ViewBalanceResponse, error) {
// todo: permission checking
// Attempt to get the balance from the db
balance, err := svc.store.GetBalance(ctx, req.CustomerId)
if err != nil {
return nil, err
}
return &pb.ViewBalanceResponse{Balance: balance}, nil
}
func (svc PaymentService) ProcessUserCreatedEvent(ctx context.Context, req *eventpb.UserCreatedEvent) (*emptypb.Empty, error) {
err := svc.store.CreateBalance(ctx, req.UserId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "error processing event", err)
}
return &emptypb.Empty{}, nil
}
func (svc PaymentService) ProcessUserDeletedEvent(ctx context.Context, req *eventpb.UserDeletedEvent) (*emptypb.Empty, error) {
err := svc.store.CloseBalance(ctx, req.UserId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "error processing event", err)
}
return &emptypb.Empty{}, nil
}
func (svc PaymentService) ProcessShipmentAllocationEvent(ctx context.Context, req *eventpb.ShipmentAllocationEvent) (*emptypb.Empty, error) {
err := svc.store.PaymentForOrder(ctx, req.OrderId, req.OrderMetadata.CustomerId, req.OrderMetadata.TotalPrice)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "error processing event", err)
}
return &emptypb.Empty{}, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"context"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/rs/zerolog/log"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/product/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/product"
)
func PrepareGateway(cfg *product.ServiceConfig) *runtime.ServeMux {
mux, clientOpts := serve.NewGatewayServeBase(&cfg.Shared)
ctx := context.Background()
err := pb.RegisterProductServiceHandlerFromEndpoint(ctx, mux, serve.GetAddrToGrpc("localhost"), clientOpts)
if err != nil {
log.Panic().Err(err).Msg("failed to register endpoint for gateway")
}
return mux
}

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"google.golang.org/grpc"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/product/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/product"
)
func PrepareGrpc(cfg *product.ServiceConfig, svc *product.ProductService) *grpc.Server {
svr := serve.NewGrpcServeBase(&cfg.Shared)
pb.RegisterProductServiceServer(svr, svc)
return svr
}

View File

@@ -0,0 +1,42 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package product
import (
"github.com/hexolan/stocklet/internal/pkg/config"
)
// Order Service Configuration
type ServiceConfig struct {
// Core Configuration
Shared config.SharedConfig
// Dynamically loaded configuration
Postgres config.PostgresConfig
Kafka config.KafkaConfig
}
// load the base service configuration
func NewServiceConfig() (*ServiceConfig, error) {
cfg := ServiceConfig{}
// Load the core configuration
if err := cfg.Shared.Load(); err != nil {
return nil, err
}
return &cfg, nil
}

View File

@@ -0,0 +1,114 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"github.com/rs/zerolog/log"
"github.com/twmb/franz-go/pkg/kgo"
"google.golang.org/protobuf/proto"
"github.com/hexolan/stocklet/internal/pkg/messaging"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/product/v1"
"github.com/hexolan/stocklet/internal/svc/product"
)
type kafkaController struct {
cl *kgo.Client
svc pb.ProductServiceServer
ctx context.Context
ctxCancel context.CancelFunc
}
func NewKafkaController(cl *kgo.Client) product.ConsumerController {
// Create a cancellable context for the consumer
ctx, ctxCancel := context.WithCancel(context.Background())
// Ensure the required Kafka topics exist
err := messaging.EnsureKafkaTopics(
cl,
messaging.Product_State_Created_Topic,
messaging.Product_State_Deleted_Topic,
messaging.Product_Attribute_Price_Topic,
messaging.Product_PriceQuotation_Topic,
messaging.Order_State_Created_Topic,
)
if err != nil {
log.Warn().Err(err).Msg("kafka: raised attempting to ensure svc topics")
}
// Add the consumption topics
cl.AddConsumeTopics(
messaging.Order_State_Created_Topic,
)
return &kafkaController{cl: cl, ctx: ctx, ctxCancel: ctxCancel}
}
func (c *kafkaController) Attach(svc pb.ProductServiceServer) {
c.svc = svc
}
func (c *kafkaController) Start() {
if c.svc == nil {
log.Panic().Msg("consumer: no service interface attached")
}
for {
fetches := c.cl.PollFetches(c.ctx)
if errs := fetches.Errors(); len(errs) > 0 {
log.Panic().Any("kafka-errs", errs).Msg("consumer: unrecoverable kafka errors")
}
fetches.EachTopic(func(ft kgo.FetchTopic) {
switch ft.Topic {
case messaging.Order_State_Created_Topic:
c.consumeOrderCreatedEventTopic(ft)
default:
log.Warn().Str("topic", ft.Topic).Msg("consumer: recieved records from unexpected topic")
}
})
}
}
func (c *kafkaController) Stop() {
// Cancel the consumer context
c.ctxCancel()
}
func (c *kafkaController) consumeOrderCreatedEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.OrderCreatedEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessOrderCreatedEvent(ctx, &event)
})
}

View File

@@ -0,0 +1,294 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"golang.org/x/exp/maps"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hexolan/stocklet/internal/pkg/errors"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/product/v1"
"github.com/hexolan/stocklet/internal/svc/product"
)
const pgProductBaseQuery string = "SELECT id, name, description, price, created_at, updated_at FROM products"
type postgresController struct {
cl *pgxpool.Pool
}
func NewPostgresController(cl *pgxpool.Pool) product.StorageController {
return postgresController{cl: cl}
}
func (c postgresController) GetProduct(ctx context.Context, productId string) (*pb.Product, error) {
return c.getProduct(ctx, nil, productId)
}
func (c postgresController) getProduct(ctx context.Context, tx *pgx.Tx, productId string) (*pb.Product, error) {
// Determine if a db transaction is being used
var row pgx.Row
const query = pgProductBaseQuery + " WHERE id=$1"
if tx == nil {
row = c.cl.QueryRow(ctx, query, productId)
} else {
row = (*tx).QueryRow(ctx, query, productId)
}
// Scan row to protobuf obj
product, err := scanRowToProduct(row)
if err != nil {
return nil, err
}
return product, nil
}
// todo: implementing pagination mechanism
func (c postgresController) GetProducts(ctx context.Context) ([]*pb.Product, error) {
rows, err := c.cl.Query(ctx, pgProductBaseQuery+" LIMIT 10")
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "query error", err)
}
products := []*pb.Product{}
for rows.Next() {
productObj, err := scanRowToProduct(rows)
if err != nil {
return nil, err
}
products = append(products, productObj)
}
if rows.Err() != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "error whilst scanning rows", rows.Err())
}
return products, nil
}
// Update a product price.
func (c postgresController) UpdateProductPrice(ctx context.Context, productId string, price float32) (*pb.Product, error) {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Update product price
_, err = tx.Exec(ctx, "UPDATE products SET price=$1 WHERE id=$2", price, productId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update product price", err)
}
// Get updated product
productObj, err := c.getProduct(ctx, &tx, productId)
if err != nil {
return nil, err
}
// Add the event to the outbox table with the transaction
evt, evtTopic, err := product.PrepareProductPriceUpdatedEvent(productObj)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", productObj.Id, evtTopic, evt)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return productObj, nil
}
// Delete a product by its specified id.
func (c postgresController) DeleteProduct(ctx context.Context, productId string) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Get product
productObj, err := c.getProduct(ctx, &tx, productId)
if err != nil {
return err
}
// Delete product
_, err = tx.Exec(ctx, "DELETE FROM products WHERE id=$1", productId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to delete product", err)
}
// Add the event to the outbox table with the transaction
evt, evtTopic, err := product.PrepareProductDeletedEvent(productObj)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", productObj.Id, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
func (c postgresController) PriceOrderProducts(ctx context.Context, orderId string, customerId string, productQuantities map[string]int32) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Get prices of all specified products (in productQuantities)
productIds := maps.Keys(productQuantities)
statement, args, err := goqu.Dialect("postgres").From("products").Select("id", "price").Where(goqu.C("id").In(productIds)).Prepared(true).ToSQL()
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to build statement", err)
}
rows, err := tx.Query(ctx, statement, args...)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to fetch price quotes", err)
}
var productPrices map[string]float32
for rows.Next() {
var productId string
var productPrice float32
err := rows.Scan(&productId, &productPrice)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeNotFound, "failed to fetch price quotes: error whilst scanning row", err)
}
productPrices[productId] = productPrice
}
if rows.Err() != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to fetch price quotes: error whilst scanning rows", rows.Err())
}
// Calculate total price
// Also ensuring that all items in the itemQuantities have a fetched price
var totalPrice float32
for productId, quantity := range productQuantities {
productPrice, ok := productPrices[productId]
if !ok {
// Prepare and dispatch failure product pricing event
evt, evtTopic, err := product.PrepareProductPriceQuoteEvent_Unavaliable(orderId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = c.cl.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", orderId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
return nil
}
// Add to total price
totalPrice += productPrice * float32(quantity)
}
// Prepare and dispatch succesful product pricing event
evt, evtTopic, err := product.PrepareProductPriceQuoteEvent_Avaliable(
orderId,
productQuantities,
productPrices,
totalPrice,
)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", orderId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
// Scan a postgres row to a protobuf object
func scanRowToProduct(row pgx.Row) (*pb.Product, error) {
var productObj pb.Product
// Temporary variables that require conversion
var tmpCreatedAt pgtype.Timestamp
var tmpUpdatedAt pgtype.Timestamp
err := row.Scan(
&productObj.Id,
&productObj.Name,
&productObj.Description,
&productObj.Price,
&tmpCreatedAt,
&tmpUpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, errors.WrapServiceError(errors.ErrCodeNotFound, "product not found", err)
} else {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to scan object from database", err)
}
}
// convert postgres timestamps to unix format
if tmpCreatedAt.Valid {
productObj.CreatedAt = tmpCreatedAt.Time.Unix()
} else {
return nil, errors.NewServiceError(errors.ErrCodeUnknown, "failed to scan object from database (timestamp conversion)")
}
if tmpUpdatedAt.Valid {
unixUpdated := tmpUpdatedAt.Time.Unix()
productObj.UpdatedAt = &unixUpdated
}
return &productObj, nil
}

View File

@@ -0,0 +1,86 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package product
import (
"github.com/hexolan/stocklet/internal/pkg/messaging"
eventspb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/product/v1"
)
func PrepareProductCreatedEvent(product *pb.Product) ([]byte, string, error) {
topic := messaging.Product_State_Created_Topic
event := &eventspb.ProductCreatedEvent{
Revision: 1,
ProductId: product.Id,
Name: product.Name,
Description: product.Description,
Price: product.Price,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareProductPriceUpdatedEvent(product *pb.Product) ([]byte, string, error) {
topic := messaging.Product_Attribute_Price_Topic
event := &eventspb.ProductPriceUpdatedEvent{
Revision: 1,
ProductId: product.Id,
Price: product.Price,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareProductDeletedEvent(product *pb.Product) ([]byte, string, error) {
topic := messaging.Product_State_Deleted_Topic
event := &eventspb.ProductDeletedEvent{
Revision: 1,
ProductId: product.Id,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareProductPriceQuoteEvent_Avaliable(orderId string, productQuantities map[string]int32, productPrices map[string]float32, totalPrice float32) ([]byte, string, error) {
topic := messaging.Product_PriceQuotation_Topic
event := &eventspb.ProductPriceQuoteEvent{
Revision: 1,
Type: eventspb.ProductPriceQuoteEvent_TYPE_AVALIABLE,
OrderId: orderId,
ProductQuantities: productQuantities,
ProductPrices: productPrices,
TotalPrice: totalPrice,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareProductPriceQuoteEvent_Unavaliable(orderId string) ([]byte, string, error) {
topic := messaging.Product_PriceQuotation_Topic
event := &eventspb.ProductPriceQuoteEvent{
Revision: 1,
Type: eventspb.ProductPriceQuoteEvent_TYPE_UNAVALIABLE,
OrderId: orderId,
}
return messaging.MarshalEvent(event, topic)
}

View File

@@ -0,0 +1,116 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package product
import (
"context"
"github.com/bufbuild/protovalidate-go"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/hexolan/stocklet/internal/pkg/errors"
"github.com/hexolan/stocklet/internal/pkg/messaging"
commonpb "github.com/hexolan/stocklet/internal/pkg/protogen/common/v1"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/product/v1"
)
// Interface for the service
type ProductService struct {
pb.UnimplementedProductServiceServer
store StorageController
pbVal *protovalidate.Validator
}
// Interface for database methods
// Flexibility for implementing seperate controllers for different databases (e.g. Postgres, MongoDB, etc)
type StorageController interface {
GetProduct(ctx context.Context, productId string) (*pb.Product, error)
GetProducts(ctx context.Context) ([]*pb.Product, error)
UpdateProductPrice(ctx context.Context, productId string, price float32) (*pb.Product, error)
DeleteProduct(ctx context.Context, productId string) error
PriceOrderProducts(ctx context.Context, orderId string, customerId string, productQuantities map[string]int32) error
}
// Interface for event consumption
// Flexibility for seperate controllers for different messaging systems (e.g. Kafka, NATS, etc)
type ConsumerController interface {
messaging.ConsumerController
Attach(svc pb.ProductServiceServer)
}
// Create the product service
func NewProductService(cfg *ServiceConfig, store StorageController) *ProductService {
// Initialise the protobuf validator
pbVal, err := protovalidate.New()
if err != nil {
log.Panic().Err(err).Msg("failed to initialise protobuf validator")
}
// Initialise the service
return &ProductService{
store: store,
pbVal: pbVal,
}
}
func (svc ProductService) ServiceInfo(ctx context.Context, req *commonpb.ServiceInfoRequest) (*commonpb.ServiceInfoResponse, error) {
return &commonpb.ServiceInfoResponse{
Name: "product",
Source: "https://github.com/hexolan/stocklet",
SourceLicense: "AGPL-3.0",
}, nil
}
func (svc ProductService) ViewProduct(ctx context.Context, req *pb.ViewProductRequest) (*pb.ViewProductResponse, error) {
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// Provide the validation error to the user.
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// Get product from DB
product, err := svc.store.GetProduct(ctx, req.Id)
if err != nil {
return nil, err
}
return &pb.ViewProductResponse{Product: product}, nil
}
func (svc ProductService) ViewProducts(ctx context.Context, req *pb.ViewProductsRequest) (*pb.ViewProductsResponse, error) {
// todo: pagination mechanism
products, err := svc.store.GetProducts(ctx)
if err != nil {
return nil, err
}
return &pb.ViewProductsResponse{Products: products}, nil
}
func (svc ProductService) ProcessOrderCreatedEvent(ctx context.Context, req *eventpb.OrderCreatedEvent) (*emptypb.Empty, error) {
err := svc.store.PriceOrderProducts(ctx, req.OrderId, req.CustomerId, req.ItemQuantities)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "error processing event", err)
}
return &emptypb.Empty{}, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"context"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/rs/zerolog/log"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/shipping/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/shipping"
)
func PrepareGateway(cfg *shipping.ServiceConfig) *runtime.ServeMux {
mux, clientOpts := serve.NewGatewayServeBase(&cfg.Shared)
ctx := context.Background()
err := pb.RegisterShippingServiceHandlerFromEndpoint(ctx, mux, serve.GetAddrToGrpc("localhost"), clientOpts)
if err != nil {
log.Panic().Err(err).Msg("failed to register endpoint for gateway")
}
return mux
}

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"google.golang.org/grpc"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/shipping/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/shipping"
)
func PrepareGrpc(cfg *shipping.ServiceConfig, svc *shipping.ShippingService) *grpc.Server {
svr := serve.NewGrpcServeBase(&cfg.Shared)
pb.RegisterShippingServiceServer(svr, svc)
return svr
}

View File

@@ -0,0 +1,42 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package shipping
import (
"github.com/hexolan/stocklet/internal/pkg/config"
)
// Order Service Configuration
type ServiceConfig struct {
// Core Configuration
Shared config.SharedConfig
// Dynamically loaded configuration
Postgres config.PostgresConfig
Kafka config.KafkaConfig
}
// load the base service configuration
func NewServiceConfig() (*ServiceConfig, error) {
cfg := ServiceConfig{}
// Load the core configuration
if err := cfg.Shared.Load(); err != nil {
return nil, err
}
return &cfg, nil
}

View File

@@ -0,0 +1,135 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"github.com/rs/zerolog/log"
"github.com/twmb/franz-go/pkg/kgo"
"google.golang.org/protobuf/proto"
"github.com/hexolan/stocklet/internal/pkg/messaging"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/shipping/v1"
"github.com/hexolan/stocklet/internal/svc/shipping"
)
type kafkaController struct {
cl *kgo.Client
svc pb.ShippingServiceServer
ctx context.Context
ctxCancel context.CancelFunc
}
func NewKafkaController(cl *kgo.Client) shipping.ConsumerController {
// Create a cancellable context for the consumer
ctx, ctxCancel := context.WithCancel(context.Background())
// Ensure the required Kafka topics exist
err := messaging.EnsureKafkaTopics(
cl,
messaging.Shipping_Shipment_Allocation_Topic,
messaging.Shipping_Shipment_Dispatched_Topic,
messaging.Warehouse_Reservation_Reserved_Topic,
messaging.Payment_Processing_Topic,
)
if err != nil {
log.Warn().Err(err).Msg("kafka: raised attempting to ensure svc topics")
}
// Add the consumption topics
cl.AddConsumeTopics(
messaging.Warehouse_Reservation_Reserved_Topic,
messaging.Payment_Processing_Topic,
)
return &kafkaController{cl: cl, ctx: ctx, ctxCancel: ctxCancel}
}
func (c *kafkaController) Attach(svc pb.ShippingServiceServer) {
c.svc = svc
}
func (c *kafkaController) Start() {
if c.svc == nil {
log.Panic().Msg("consumer: no service interface attached")
}
for {
fetches := c.cl.PollFetches(c.ctx)
if errs := fetches.Errors(); len(errs) > 0 {
log.Panic().Any("kafka-errs", errs).Msg("consumer: unrecoverable kafka errors")
}
fetches.EachTopic(func(ft kgo.FetchTopic) {
switch ft.Topic {
case messaging.Warehouse_Reservation_Reserved_Topic:
c.consumeStockReservationEventTopic(ft)
case messaging.Payment_Processing_Topic:
c.consumePaymentProcessedEventTopic(ft)
default:
log.Warn().Str("topic", ft.Topic).Msg("consumer: recieved records from unexpected topic")
}
})
}
}
func (c *kafkaController) Stop() {
// Cancel the consumer context
c.ctxCancel()
}
func (c *kafkaController) consumeStockReservationEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.StockReservationEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessStockReservationEvent(ctx, &event)
})
}
func (c *kafkaController) consumePaymentProcessedEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.PaymentProcessedEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessPaymentProcessedEvent(ctx, &event)
})
}

View File

@@ -0,0 +1,256 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hexolan/stocklet/internal/pkg/errors"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/shipping/v1"
"github.com/hexolan/stocklet/internal/svc/shipping"
)
const (
pgShipmentBaseQuery string = "SELECT id, order_id, dispatched, created_at FROM shipments"
pgShipmentItemsBaseQuery string = "SELECT shipment_id, product_id, quantity FROM shipment_items"
)
type postgresController struct {
cl *pgxpool.Pool
}
func NewPostgresController(cl *pgxpool.Pool) shipping.StorageController {
return postgresController{cl: cl}
}
func (c postgresController) GetShipment(ctx context.Context, shipmentId string) (*pb.Shipment, error) {
return c.getShipment(ctx, nil, shipmentId)
}
func (c postgresController) getShipment(ctx context.Context, tx *pgx.Tx, shipmentId string) (*pb.Shipment, error) {
// Determine if a db transaction is being used
var row pgx.Row
const query = pgShipmentBaseQuery + " WHERE id=$1"
if tx == nil {
row = c.cl.QueryRow(ctx, query, shipmentId)
} else {
row = (*tx).QueryRow(ctx, query, shipmentId)
}
// Scan row to protobuf obj
shipment, err := scanRowToShipment(row)
if err != nil {
return nil, err
}
return shipment, nil
}
func (c postgresController) getShipmentByOrderId(ctx context.Context, tx *pgx.Tx, orderId string) (*pb.Shipment, error) {
// Determine if a db transaction is being used
var row pgx.Row
const query = pgShipmentBaseQuery + " WHERE order_id=$1"
if tx == nil {
row = c.cl.QueryRow(ctx, query, orderId)
} else {
row = (*tx).QueryRow(ctx, query, orderId)
}
// Scan row to protobuf obj
shipment, err := scanRowToShipment(row)
if err != nil {
return nil, err
}
return shipment, nil
}
func (c postgresController) GetShipmentItems(ctx context.Context, shipmentId string) ([]*pb.ShipmentItem, error) {
return c.getShipmentItems(ctx, nil, shipmentId)
}
func (c postgresController) getShipmentItems(ctx context.Context, tx *pgx.Tx, shipmentId string) ([]*pb.ShipmentItem, error) {
// Determine if transaction is being used
var rows pgx.Rows
var err error
if tx == nil {
rows, err = c.cl.Query(ctx, pgShipmentItemsBaseQuery+" WHERE shipment_id=$1", shipmentId)
} else {
rows, err = (*tx).Query(ctx, pgShipmentItemsBaseQuery+" WHERE shipment_id=$1", shipmentId)
}
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "query error whilst fetching items", err)
}
shipmentItems := []*pb.ShipmentItem{}
for rows.Next() {
var shipmentItem pb.ShipmentItem
shipmentItem.ShipmentId = shipmentId
err := rows.Scan(
&shipmentItem.ProductId,
&shipmentItem.Quantity,
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to scan an order item", err)
}
shipmentItems = append(shipmentItems, &shipmentItem)
}
if rows.Err() != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "error whilst scanning order item rows", rows.Err())
}
return shipmentItems, nil
}
func (c postgresController) AllocateOrderShipment(ctx context.Context, orderId string, orderMetadata shipping.EventOrderMetadata, productQuantities map[string]int32) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Create shipment
var shipmentId string
err = tx.QueryRow(ctx, "INSERT INTO shipments (order_id) VALUES ($1) RETURNING id", orderId).Scan(&shipmentId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to create shipment", err)
}
// Add shipment items
vals := [][]interface{}{}
for productId, quantity := range productQuantities {
vals = append(
vals,
goqu.Vals{shipmentId, productId, quantity},
)
}
statement, args, err := goqu.Dialect("postgres").From("shipment_items").Insert().Cols("shipment_id", "product_id", "quantity").Vals(vals...).Prepared(true).ToSQL()
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to build statement", err)
}
_, err = tx.Exec(ctx, statement, args...)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to add shipment items", err)
}
// Prepare and append shipment allocated event to transaction
evt, evtTopic, err := shipping.PrepareShipmentAllocationEvent_Allocated(orderId, orderMetadata, shipmentId, productQuantities)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", shipmentId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
func (c postgresController) CancelOrderShipment(ctx context.Context, orderId string) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Get the shipment
shipment, err := c.getShipmentByOrderId(ctx, &tx, orderId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to fetch shipment info", err)
}
// Get the shipment items
shipmentItems, err := c.getShipmentItems(ctx, &tx, shipment.Id)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to fetch shipment manifest", err)
}
// Delete shipment
_, err = tx.Exec(ctx, "DELETE FROM shipments WHERE id=$1", shipment.Id)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to delete shipment", err)
}
// Add the event to the outbox table with the transaction
evt, evtTopic, err := shipping.PrepareShipmentAllocationEvent_AllocationReleased(orderId, shipment.Id, shipmentItems)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", shipment.Id, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
// Scan a postgres row to a protobuf object
func scanRowToShipment(row pgx.Row) (*pb.Shipment, error) {
var shipment pb.Shipment
// Temporary variables that require conversion
var tmpCreatedAt pgtype.Timestamp
err := row.Scan(
&shipment.Id,
&shipment.OrderId,
&shipment.Dispatched,
&tmpCreatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, errors.WrapServiceError(errors.ErrCodeNotFound, "shipment not found", err)
} else {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "something went wrong scanning object", err)
}
}
// convert postgres timestamps to unix format
if tmpCreatedAt.Valid {
shipment.CreatedAt = tmpCreatedAt.Time.Unix()
} else {
return nil, errors.NewServiceError(errors.ErrCodeUnknown, "something went wrong scanning object (timestamp conversion error)")
}
return &shipment, nil
}

View File

@@ -0,0 +1,97 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package shipping
import (
"github.com/hexolan/stocklet/internal/pkg/messaging"
eventspb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/shipping/v1"
)
type EventOrderMetadata struct {
CustomerId string
ItemsPrice float32
TotalPrice float32
}
func PrepareShipmentAllocationEvent_Failed(orderId string, orderMetadata EventOrderMetadata, productQuantities map[string]int32) ([]byte, string, error) {
topic := messaging.Shipping_Shipment_Allocation_Topic
event := &eventspb.ShipmentAllocationEvent{
Revision: 1,
Type: eventspb.ShipmentAllocationEvent_TYPE_FAILED,
OrderId: orderId,
OrderMetadata: &eventspb.ShipmentAllocationEvent_OrderMetadata{
CustomerId: orderMetadata.CustomerId,
ItemsPrice: orderMetadata.ItemsPrice,
TotalPrice: orderMetadata.TotalPrice,
},
ProductQuantities: productQuantities,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareShipmentAllocationEvent_Allocated(orderId string, orderMetadata EventOrderMetadata, shipmentId string, productQuantities map[string]int32) ([]byte, string, error) {
topic := messaging.Shipping_Shipment_Allocation_Topic
event := &eventspb.ShipmentAllocationEvent{
Revision: 1,
Type: eventspb.ShipmentAllocationEvent_TYPE_ALLOCATED,
OrderId: orderId,
OrderMetadata: &eventspb.ShipmentAllocationEvent_OrderMetadata{
CustomerId: orderMetadata.CustomerId,
ItemsPrice: orderMetadata.ItemsPrice,
TotalPrice: orderMetadata.TotalPrice,
},
ShipmentId: shipmentId,
ProductQuantities: productQuantities,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareShipmentAllocationEvent_AllocationReleased(orderId string, shipmentId string, shipmentItems []*pb.ShipmentItem) ([]byte, string, error) {
productQuantities := make(map[string]int32)
for _, item := range shipmentItems {
productQuantities[item.ProductId] = item.Quantity
}
topic := messaging.Shipping_Shipment_Allocation_Topic
event := &eventspb.ShipmentAllocationEvent{
Revision: 1,
Type: eventspb.ShipmentAllocationEvent_TYPE_ALLOCATION_RELEASED,
OrderId: orderId,
ShipmentId: shipmentId,
ProductQuantities: productQuantities,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareShipmentDispatchedEvent(orderId string, shipmentId string, productQuantities map[string]int32) ([]byte, string, error) {
topic := messaging.Shipping_Shipment_Dispatched_Topic
event := &eventspb.ShipmentDispatchedEvent{
Revision: 1,
OrderId: orderId,
ShipmentId: shipmentId,
ProductQuantities: productQuantities,
}
return messaging.MarshalEvent(event, topic)
}

View File

@@ -0,0 +1,147 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package shipping
import (
"context"
"github.com/bufbuild/protovalidate-go"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/hexolan/stocklet/internal/pkg/errors"
"github.com/hexolan/stocklet/internal/pkg/messaging"
commonpb "github.com/hexolan/stocklet/internal/pkg/protogen/common/v1"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/shipping/v1"
)
// Interface for the service
type ShippingService struct {
pb.UnimplementedShippingServiceServer
store StorageController
pbVal *protovalidate.Validator
}
// Interface for database methods
// Flexibility for implementing seperate controllers for different databases (e.g. Postgres, MongoDB, etc)
type StorageController interface {
GetShipment(ctx context.Context, shipmentId string) (*pb.Shipment, error)
GetShipmentItems(ctx context.Context, shipmentId string) ([]*pb.ShipmentItem, error)
AllocateOrderShipment(ctx context.Context, orderId string, orderMetadata EventOrderMetadata, productQuantities map[string]int32) error
CancelOrderShipment(ctx context.Context, orderId string) error
}
// Interface for event consumption
// Flexibility for seperate controllers for different messaging systems (e.g. Kafka, NATS, etc)
type ConsumerController interface {
messaging.ConsumerController
Attach(svc pb.ShippingServiceServer)
}
// Create the shipping service
func NewShippingService(cfg *ServiceConfig, store StorageController) *ShippingService {
// Initialise the protobuf validator
pbVal, err := protovalidate.New()
if err != nil {
log.Panic().Err(err).Msg("failed to initialise protobuf validator")
}
// Initialise the service
return &ShippingService{
store: store,
pbVal: pbVal,
}
}
func (svc ShippingService) ServiceInfo(ctx context.Context, req *commonpb.ServiceInfoRequest) (*commonpb.ServiceInfoResponse, error) {
return &commonpb.ServiceInfoResponse{
Name: "shipping",
Source: "https://github.com/hexolan/stocklet",
SourceLicense: "AGPL-3.0",
}, nil
}
func (svc ShippingService) ViewShipment(ctx context.Context, req *pb.ViewShipmentRequest) (*pb.ViewShipmentResponse, error) {
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// Provide the validation error to the user.
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// todo: permission checking?
// Get shipment from DB
shipment, err := svc.store.GetShipment(ctx, req.ShipmentId)
if err != nil {
return nil, err
}
return &pb.ViewShipmentResponse{Shipment: shipment}, nil
}
func (svc ShippingService) ViewShipmentManifest(ctx context.Context, req *pb.ViewShipmentManifestRequest) (*pb.ViewShipmentManifestResponse, error) {
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// Provide the validation error to the user.
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// todo: permission checking?
shipmentItems, err := svc.store.GetShipmentItems(ctx, req.ShipmentId)
if err != nil {
return nil, err
}
return &pb.ViewShipmentManifestResponse{Manifest: shipmentItems}, nil
}
func (svc ShippingService) ProcessStockReservationEvent(ctx context.Context, req *eventpb.StockReservationEvent) (*emptypb.Empty, error) {
if req.Type == eventpb.StockReservationEvent_TYPE_STOCK_RESERVED {
err := svc.store.AllocateOrderShipment(
ctx,
req.OrderId,
EventOrderMetadata{
CustomerId: req.OrderMetadata.CustomerId,
ItemsPrice: req.OrderMetadata.ItemsPrice,
TotalPrice: req.OrderMetadata.TotalPrice,
},
req.ReservationStock,
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update in response to event", err)
}
}
return &emptypb.Empty{}, nil
}
func (svc ShippingService) ProcessPaymentProcessedEvent(ctx context.Context, req *eventpb.PaymentProcessedEvent) (*emptypb.Empty, error) {
if req.Type == eventpb.PaymentProcessedEvent_TYPE_FAILED {
err := svc.store.CancelOrderShipment(ctx, req.OrderId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to update in response to event", err)
}
}
return &emptypb.Empty{}, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"context"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/rs/zerolog/log"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/user/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/user"
)
func PrepareGateway(cfg *user.ServiceConfig) *runtime.ServeMux {
mux, clientOpts := serve.NewGatewayServeBase(&cfg.Shared)
ctx := context.Background()
err := pb.RegisterUserServiceHandlerFromEndpoint(ctx, mux, serve.GetAddrToGrpc("localhost"), clientOpts)
if err != nil {
log.Panic().Err(err).Msg("failed to register endpoint for gateway")
}
return mux
}

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"google.golang.org/grpc"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/user/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/user"
)
func PrepareGrpc(cfg *user.ServiceConfig, svc *user.UserService) *grpc.Server {
svr := serve.NewGrpcServeBase(&cfg.Shared)
pb.RegisterUserServiceServer(svr, svc)
return svr
}

View File

@@ -0,0 +1,65 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package user
import (
"github.com/hexolan/stocklet/internal/pkg/config"
)
// Order Service Configuration
type ServiceConfig struct {
// Core Configuration
Shared config.SharedConfig
ServiceOpts ServiceConfigOpts
// Dynamically loaded configuration
Postgres config.PostgresConfig
}
// load the base service configuration
func NewServiceConfig() (*ServiceConfig, error) {
cfg := ServiceConfig{}
// Load the core configuration
if err := cfg.Shared.Load(); err != nil {
return nil, err
}
// load the service config opts
if err := cfg.ServiceOpts.Load(); err != nil {
return nil, err
}
return &cfg, nil
}
// Service specific config options
type ServiceConfigOpts struct {
// Env Var: "AUTH_SERVICE_GRPC"
AuthServiceGrpc string
}
// Load the ServiceConfigOpts
func (opts *ServiceConfigOpts) Load() error {
// Load configurations from env
if authServiceGrpc, err := config.RequireFromEnv("AUTH_SERVICE_GRPC"); err == nil {
opts.AuthServiceGrpc = authServiceGrpc
} else {
return err
}
return nil
}

View File

@@ -0,0 +1,247 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/hexolan/stocklet/internal/pkg/errors"
authpb "github.com/hexolan/stocklet/internal/pkg/protogen/auth/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/user/v1"
"github.com/hexolan/stocklet/internal/svc/user"
)
const pgUserBaseQuery string = "SELECT id, first_name, last_name, email, created_at, updated_at FROM users"
type postgresController struct {
cl *pgxpool.Pool
serviceOpts *user.ServiceConfigOpts
}
func NewPostgresController(cl *pgxpool.Pool, serviceOpts *user.ServiceConfigOpts) user.StorageController {
return postgresController{cl: cl, serviceOpts: serviceOpts}
}
func (c postgresController) GetUser(ctx context.Context, userId string) (*pb.User, error) {
return c.getUser(ctx, nil, userId)
}
func (c postgresController) getUser(ctx context.Context, tx *pgx.Tx, userId string) (*pb.User, error) {
// Determine if a db transaction is being used
var row pgx.Row
const query = pgUserBaseQuery + " WHERE id=$1"
if tx == nil {
row = c.cl.QueryRow(ctx, query, userId)
} else {
row = (*tx).QueryRow(ctx, query, userId)
}
// Scan row to protobuf obj
userObj, err := scanRowToUser(row)
if err != nil {
return nil, err
}
return userObj, nil
}
func (c postgresController) RegisterUser(ctx context.Context, email string, password string, firstName string, lastName string) (*pb.User, error) {
// Establish connection with auth service
authConn, err := grpc.Dial(
c.serviceOpts.AuthServiceGrpc,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to establish connection to auth service", err)
}
defer authConn.Close()
authCl := authpb.NewAuthServiceClient(authConn)
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin db transaction", err)
}
defer tx.Rollback(ctx)
// Create user in database
var userId string
err = tx.QueryRow(
ctx,
"INSERT INTO users (first_name, last_name, email) VALUES ($1, $2, $3) RETURNING id",
firstName,
lastName,
email,
).Scan(&userId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to create user", err)
}
// Get the newly created user obj
userObj, err := c.getUser(ctx, &tx, userId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to register user", err)
}
// Prepare user created event and append to transaction
evt, evtTopic, err := user.PrepareUserCreatedEvent(userObj)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", userObj.Id, evtTopic, evt)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Attempt to add auth method for user
authCtx, authCtxCancel := context.WithTimeout(context.Background(), time.Second*10)
defer authCtxCancel()
_, err = authCl.SetPassword(authCtx, &authpb.SetPasswordRequest{UserId: userObj.Id, Password: password})
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "error registering user: failed to set auth method", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return userObj, nil
}
func (c postgresController) UpdateUserEmail(ctx context.Context, userId string, email string) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Update email
_, err = tx.Exec(ctx, "UPDATE users SET email=$1 WHERE id=$2", email, userId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to update email", err)
}
// Add the event to the outbox table with the transaction
evt, evtTopic, err := user.PrepareUserEmailUpdatedEvent(userId, email)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", userId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
func (c postgresController) DeleteUser(ctx context.Context, userId string) (*pb.User, error) {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Get the user
userObj, err := c.getUser(ctx, &tx, userId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to fetch user", err)
}
// Delete user
_, err = tx.Exec(ctx, "DELETE FROM users WHERE id=$1", userId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to delete user", err)
}
// Add the event to the outbox table with the transaction
evt, evtTopic, err := user.PrepareUserDeletedEvent(userObj)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", userObj.Id, evtTopic, evt)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return userObj, nil
}
// Scan a postgres row to a protobuf object
func scanRowToUser(row pgx.Row) (*pb.User, error) {
var user pb.User
// Temporary variables that require conversion
var tmpCreatedAt pgtype.Timestamp
var tmpUpdatedAt pgtype.Timestamp
err := row.Scan(
&user.Id,
&user.FirstName,
&user.LastName,
&user.Email,
&tmpCreatedAt,
&tmpUpdatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, errors.WrapServiceError(errors.ErrCodeNotFound, "user not found", err)
} else {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "failed to scan object from database", err)
}
}
// convert postgres timestamps to unix format
if tmpCreatedAt.Valid {
user.CreatedAt = tmpCreatedAt.Time.Unix()
} else {
return nil, errors.NewServiceError(errors.ErrCodeUnknown, "failed to scan object from database (timestamp conversion)")
}
if tmpUpdatedAt.Valid {
unixUpdated := tmpUpdatedAt.Time.Unix()
user.UpdatedAt = &unixUpdated
}
return &user, nil
}

View File

@@ -0,0 +1,60 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package user
import (
"github.com/hexolan/stocklet/internal/pkg/messaging"
eventspb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/user/v1"
)
func PrepareUserCreatedEvent(user *pb.User) ([]byte, string, error) {
topic := messaging.User_State_Created_Topic
event := &eventspb.UserCreatedEvent{
Revision: 1,
UserId: user.Id,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareUserEmailUpdatedEvent(userId string, email string) ([]byte, string, error) {
topic := messaging.User_Attribute_Email_Topic
event := &eventspb.UserEmailUpdatedEvent{
Revision: 1,
UserId: userId,
Email: email,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareUserDeletedEvent(user *pb.User) ([]byte, string, error) {
topic := messaging.User_State_Deleted_Topic
event := &eventspb.UserDeletedEvent{
Revision: 1,
UserId: user.Id,
Email: user.Email,
}
return messaging.MarshalEvent(event, topic)
}

111
internal/svc/user/user.go Normal file
View File

@@ -0,0 +1,111 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package user
import (
"context"
"github.com/bufbuild/protovalidate-go"
"github.com/rs/zerolog/log"
"github.com/hexolan/stocklet/internal/pkg/errors"
"github.com/hexolan/stocklet/internal/pkg/messaging"
commonpb "github.com/hexolan/stocklet/internal/pkg/protogen/common/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/user/v1"
)
// Interface for the service
type UserService struct {
pb.UnimplementedUserServiceServer
store StorageController
pbVal *protovalidate.Validator
}
// Interface for database methods
// Flexibility for implementing seperate controllers for different databases (e.g. Postgres, MongoDB, etc)
type StorageController interface {
GetUser(ctx context.Context, userId string) (*pb.User, error)
RegisterUser(ctx context.Context, email string, password string, firstName string, lastName string) (*pb.User, error)
UpdateUserEmail(ctx context.Context, userId string, email string) error
DeleteUser(ctx context.Context, userId string) (*pb.User, error)
}
// Interface for event consumption
// Flexibility for seperate controllers for different messaging systems (e.g. Kafka, NATS, etc)
type ConsumerController interface {
messaging.ConsumerController
Attach(svc pb.UserServiceServer)
}
// Create the shipping service
func NewUserService(cfg *ServiceConfig, store StorageController) *UserService {
// Initialise the protobuf validator
pbVal, err := protovalidate.New()
if err != nil {
log.Panic().Err(err).Msg("failed to initialise protobuf validator")
}
// Initialise the service
return &UserService{
store: store,
pbVal: pbVal,
}
}
func (svc UserService) ServiceInfo(ctx context.Context, req *commonpb.ServiceInfoRequest) (*commonpb.ServiceInfoResponse, error) {
return &commonpb.ServiceInfoResponse{
Name: "user",
Source: "https://github.com/hexolan/stocklet",
SourceLicense: "AGPL-3.0",
}, nil
}
func (svc UserService) ViewUser(ctx context.Context, req *pb.ViewUserRequest) (*pb.ViewUserResponse, error) {
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// Provide the validation error to the user.
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// Get user from DB
user, err := svc.store.GetUser(ctx, req.Id)
if err != nil {
return nil, err
}
return &pb.ViewUserResponse{User: user}, nil
}
func (svc UserService) RegisterUser(ctx context.Context, req *pb.RegisterUserRequest) (*pb.RegisterUserResponse, error) {
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// Provide the validation error to the user.
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// Attempt to register the user
// This process involves calling the auth service to add an auth method for the user
user, err := svc.store.RegisterUser(ctx, req.Email, req.Password, req.FirstName, req.LastName)
if err != nil {
return nil, err
}
return &pb.RegisterUserResponse{User: user}, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"context"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/rs/zerolog/log"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/warehouse/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/warehouse"
)
func PrepareGateway(cfg *warehouse.ServiceConfig) *runtime.ServeMux {
mux, clientOpts := serve.NewGatewayServeBase(&cfg.Shared)
ctx := context.Background()
err := pb.RegisterWarehouseServiceHandlerFromEndpoint(ctx, mux, serve.GetAddrToGrpc("localhost"), clientOpts)
if err != nil {
log.Panic().Err(err).Msg("failed to register endpoint for gateway")
}
return mux
}

View File

@@ -0,0 +1,30 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package api
import (
"google.golang.org/grpc"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/warehouse/v1"
"github.com/hexolan/stocklet/internal/pkg/serve"
"github.com/hexolan/stocklet/internal/svc/warehouse"
)
func PrepareGrpc(cfg *warehouse.ServiceConfig, svc *warehouse.WarehouseService) *grpc.Server {
svr := serve.NewGrpcServeBase(&cfg.Shared)
pb.RegisterWarehouseServiceServer(svr, svc)
return svr
}

View File

@@ -0,0 +1,42 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package warehouse
import (
"github.com/hexolan/stocklet/internal/pkg/config"
)
// Order Service Configuration
type ServiceConfig struct {
// Core Configuration
Shared config.SharedConfig
// Dynamically loaded configuration
Postgres config.PostgresConfig
Kafka config.KafkaConfig
}
// load the base service configuration
func NewServiceConfig() (*ServiceConfig, error) {
cfg := ServiceConfig{}
// Load the core configuration
if err := cfg.Shared.Load(); err != nil {
return nil, err
}
return &cfg, nil
}

View File

@@ -0,0 +1,161 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"github.com/rs/zerolog/log"
"github.com/twmb/franz-go/pkg/kgo"
"google.golang.org/protobuf/proto"
"github.com/hexolan/stocklet/internal/pkg/messaging"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/warehouse/v1"
"github.com/hexolan/stocklet/internal/svc/warehouse"
)
type kafkaController struct {
cl *kgo.Client
svc pb.WarehouseServiceServer
ctx context.Context
ctxCancel context.CancelFunc
}
func NewKafkaController(cl *kgo.Client) warehouse.ConsumerController {
// Create a cancellable context for the consumer
ctx, ctxCancel := context.WithCancel(context.Background())
// Ensure the required Kafka topics exist
err := messaging.EnsureKafkaTopics(
cl,
messaging.Warehouse_Stock_Created_Topic,
messaging.Warehouse_Stock_Added_Topic,
messaging.Warehouse_Stock_Removed_Topic,
messaging.Warehouse_Reservation_Failed_Topic,
messaging.Warehouse_Reservation_Reserved_Topic,
messaging.Warehouse_Reservation_Returned_Topic,
messaging.Warehouse_Reservation_Consumed_Topic,
messaging.Order_State_Pending_Topic,
messaging.Shipping_Shipment_Allocation_Topic,
messaging.Payment_Processing_Topic,
)
if err != nil {
log.Warn().Err(err).Msg("kafka: raised attempting to ensure svc topics")
}
// Add the consumption topics
cl.AddConsumeTopics(
messaging.Order_State_Pending_Topic,
messaging.Shipping_Shipment_Allocation_Topic,
messaging.Payment_Processing_Topic,
)
return &kafkaController{cl: cl, ctx: ctx, ctxCancel: ctxCancel}
}
func (c *kafkaController) Attach(svc pb.WarehouseServiceServer) {
c.svc = svc
}
func (c *kafkaController) Start() {
if c.svc == nil {
log.Panic().Msg("consumer: no service interface attached")
}
for {
fetches := c.cl.PollFetches(c.ctx)
if errs := fetches.Errors(); len(errs) > 0 {
log.Panic().Any("kafka-errs", errs).Msg("consumer: unrecoverable kafka errors")
}
fetches.EachTopic(func(ft kgo.FetchTopic) {
switch ft.Topic {
case messaging.Order_State_Pending_Topic:
c.consumeOrderPendingEventTopic(ft)
case messaging.Shipping_Shipment_Allocation_Topic:
c.consumeShipmentAllocationEventTopic(ft)
case messaging.Payment_Processing_Topic:
c.consumePaymentProcessedEventTopic(ft)
default:
log.Warn().Str("topic", ft.Topic).Msg("consumer: recieved records from unexpected topic")
}
})
}
}
func (c *kafkaController) Stop() {
// Cancel the consumer context
c.ctxCancel()
}
func (c *kafkaController) consumeOrderPendingEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.OrderPendingEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessOrderPendingEvent(ctx, &event)
})
}
func (c *kafkaController) consumeShipmentAllocationEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.ShipmentAllocationEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessShipmentAllocationEvent(ctx, &event)
})
}
func (c *kafkaController) consumePaymentProcessedEventTopic(ft kgo.FetchTopic) {
log.Info().Str("topic", ft.Topic).Msg("consumer: recieved records from topic")
// Process each message from the topic
ft.EachRecord(func(record *kgo.Record) {
// Unmarshal the event
var event eventpb.PaymentProcessedEvent
err := proto.Unmarshal(record.Value, &event)
if err != nil {
log.Panic().Err(err).Msg("consumer: failed to unmarshal event")
}
// Process the event
ctx := context.Background()
c.svc.ProcessPaymentProcessedEvent(ctx, &event)
})
}

View File

@@ -0,0 +1,500 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hexolan/stocklet/internal/pkg/errors"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/warehouse/v1"
"github.com/hexolan/stocklet/internal/svc/warehouse"
)
const (
pgProductStockBaseQuery string = "SELECT product_id, quantity FROM product_stock"
pgReservationBaseQuery string = "SELECT id, order_id, created_at FROM reservations"
pgReservationItemsBaseQuery string = "SELECT product_id, quantity FROM reservation_items"
)
type postgresController struct {
cl *pgxpool.Pool
}
func NewPostgresController(cl *pgxpool.Pool) warehouse.StorageController {
return postgresController{cl: cl}
}
func (c postgresController) GetProductStock(ctx context.Context, productId string) (*pb.ProductStock, error) {
return c.getProductStock(ctx, nil, productId)
}
func (c postgresController) getProductStock(ctx context.Context, tx *pgx.Tx, productId string) (*pb.ProductStock, error) {
// Determine if a db transaction is being used
var row pgx.Row
const query = pgProductStockBaseQuery + " WHERE product_id=$1"
if tx == nil {
row = c.cl.QueryRow(ctx, query, productId)
} else {
row = (*tx).QueryRow(ctx, query, productId)
}
// Scan row to protobuf obj
stock, err := scanRowToProductStock(row)
if err != nil {
return nil, err
}
return stock, nil
}
func (c postgresController) GetReservation(ctx context.Context, reservationId string) (*pb.Reservation, error) {
return c.getReservation(ctx, nil, reservationId)
}
func (c postgresController) getReservation(ctx context.Context, tx *pgx.Tx, reservationId string) (*pb.Reservation, error) {
// Determine if a db transaction is being used
var row pgx.Row
const query = pgReservationBaseQuery + " WHERE id=$1"
if tx == nil {
row = c.cl.QueryRow(ctx, query, reservationId)
} else {
row = (*tx).QueryRow(ctx, query, reservationId)
}
// Scan row to protobuf obj
reservation, err := scanRowToReservation(row)
if err != nil {
return nil, err
}
// Append items to reservation
reservation, err = c.appendItemsToReservation(ctx, tx, reservation)
if err != nil {
return nil, err
}
return reservation, nil
}
func (c postgresController) getReservationByOrderId(ctx context.Context, tx *pgx.Tx, orderId string) (*pb.Reservation, error) {
// Determine if a db transaction is being used
var row pgx.Row
const query = pgReservationBaseQuery + " WHERE order_id=$1"
if tx == nil {
row = c.cl.QueryRow(ctx, query, orderId)
} else {
row = (*tx).QueryRow(ctx, query, orderId)
}
// Scan row to protobuf obj
reservation, err := scanRowToReservation(row)
if err != nil {
return nil, err
}
// Append items to reservation
reservation, err = c.appendItemsToReservation(ctx, tx, reservation)
if err != nil {
return nil, err
}
return reservation, nil
}
func (c postgresController) CreateProductStock(ctx context.Context, productId string, startingQuantity int32) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Create stock
_, err = tx.Exec(ctx, "INSERT INTO product_stock (product_id, quantity) VALUES ($1, $2)", productId, startingQuantity)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to create product stock", err)
}
// Add the event to the outbox table with the transaction
evt, evtTopic, err := warehouse.PrepareStockCreatedEvent(&pb.ProductStock{ProductId: productId, Quantity: startingQuantity})
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", productId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
func (c postgresController) ReserveOrderStock(ctx context.Context, orderId string, orderMetadata warehouse.EventOrderMetadata, productQuantities map[string]int32) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Create reservation
var reservationId string
err = tx.QueryRow(ctx, "INSERT INTO reservations (order_id) VALUES ($1) RETURNING id", orderId).Scan(&reservationId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to create reservation", err)
}
// Reserve the items
insufficientStockProductIds := []string{}
for productId, quantity := range productQuantities {
err = c.reserveStock(ctx, &tx, reservationId, productId, quantity)
if err != nil {
insufficientStockProductIds = append(insufficientStockProductIds, productId)
}
}
// Ensure that all of the stock was reserved
if len(insufficientStockProductIds) > 0 {
// Add the event to the outbox table with the transaction
evt, evtTopic, err := warehouse.PrepareStockReservationEvent_Failed(orderId, orderMetadata, insufficientStockProductIds)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = c.cl.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", orderId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
return nil
}
// Add the event to the outbox table with the transaction
evt, evtTopic, err := warehouse.PrepareStockReservationEvent_Reserved(orderId, orderMetadata, reservationId, productQuantities)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", reservationId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
func (c postgresController) reserveStock(ctx context.Context, tx *pgx.Tx, reservationId string, productId string, quantity int32) error {
// Determine if a transaction has already been provided
var (
funcTx pgx.Tx
err error
)
if tx != nil {
funcTx = *tx
} else {
funcTx, err = c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer funcTx.Rollback(ctx)
}
// Subtract from quantity
_, err = funcTx.Exec(ctx, "UPDATE product_stock SET quantity = quantity - $1 WHERE product_id=$2", quantity, productId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to update product stock", err)
}
// Get updated stock
stock, err := c.getProductStock(ctx, tx, productId)
if err != nil {
return err
}
// Ensure that the stock is not negative
if stock.Quantity < 0 {
return errors.NewServiceError(errors.ErrCodeInvalidArgument, "insufficient stock")
}
// Add as reservation item
_, err = funcTx.Exec(ctx, "INSERT INTO reservation_items (reservation_id, product_id, quantity) VALUES ($1, $2, $3)", reservationId, productId, quantity)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to add as reservation item", err)
}
// Commit the transaction (if created in this func)
if tx == nil {
err = funcTx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
}
return nil
}
func (c postgresController) ReturnReservedOrderStock(ctx context.Context, orderId string) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Get the reservation
reservation, err := c.getReservationByOrderId(ctx, &tx, orderId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to locate order reservation", err)
}
// Return all of the reserved stock
for _, reservedStock := range reservation.ReservedStock {
err = c.returnReservedStock(ctx, &tx, reservation.Id, reservedStock.ProductId, reservedStock.Quantity)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to return reserved stock", err)
}
}
// Delete the reservation
_, err = tx.Exec(ctx, "DELETE FROM reservations WHERE id=$1", reservation.Id)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to delete reservation", err)
}
// Prepare and add reservation consumed event to outbox
evt, evtTopic, err := warehouse.PrepareStockReservationEvent_Returned(reservation.OrderId, reservation.Id, reservation.ReservedStock)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", reservation.Id, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
func (c postgresController) returnReservedStock(ctx context.Context, tx *pgx.Tx, reservationId string, productId string, quantity int32) error {
// Determine if a transaction has already been provided
var (
funcTx pgx.Tx
err error
)
if tx != nil {
funcTx = *tx
} else {
funcTx, err = c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer funcTx.Rollback(ctx)
}
// Add back to stock quantity
_, err = funcTx.Exec(ctx, "UPDATE product_stock SET quantity = quantity + $1 WHERE product_id=$2", quantity, productId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to update product stock", err)
}
// Delete reservation item
_, err = funcTx.Exec(ctx, "DELETE FROM reservation_items WHERE reservation_id=$1 AND product_id=$2", reservationId, productId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to add as reservation item", err)
}
// Commit the transaction (if created in this func)
if tx == nil {
err = funcTx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
}
return nil
}
func (c postgresController) ConsumeReservedOrderStock(ctx context.Context, orderId string) error {
// Begin a DB transaction
tx, err := c.cl.Begin(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to begin transaction", err)
}
defer tx.Rollback(ctx)
// Get the reservation
reservation, err := c.getReservationByOrderId(ctx, &tx, orderId)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to locate order reservation", err)
}
// Delete the reservation
_, err = tx.Exec(ctx, "DELETE FROM reservations WHERE id=$1", reservation.Id)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to delete reservation", err)
}
// Dispatch stock removed events
for _, reservedStock := range reservation.ReservedStock {
evt, evtTopic, err := warehouse.PrepareStockRemovedEvent(reservedStock.ProductId, reservedStock.Quantity, &reservation.Id)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", reservedStock.ProductId, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
}
// Prepare and add reservation consumed event to outbox
evt, evtTopic, err := warehouse.PrepareStockReservationEvent_Consumed(reservation.OrderId, reservation.Id, reservation.ReservedStock)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeService, "failed to create event", err)
}
_, err = tx.Exec(ctx, "INSERT INTO event_outbox (aggregateid, aggregatetype, payload) VALUES ($1, $2, $3)", reservation.Id, evtTopic, evt)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to insert event", err)
}
// Commit the transaction
err = tx.Commit(ctx)
if err != nil {
return errors.WrapServiceError(errors.ErrCodeExtService, "failed to commit transaction", err)
}
return nil
}
func (c postgresController) getReservationItems(ctx context.Context, tx *pgx.Tx, reservationId string) ([]*pb.ReservationStock, error) {
// Determine if transaction is being used.
var rows pgx.Rows
var err error
const query = pgReservationItemsBaseQuery + " WHERE reservation_id=$1"
if tx == nil {
rows, err = c.cl.Query(ctx, query, reservationId)
} else {
rows, err = (*tx).Query(ctx, query, reservationId)
}
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "query error whilst fetching reserved items", err)
}
items := []*pb.ReservationStock{}
for rows.Next() {
var reservStock pb.ReservationStock
err := rows.Scan(
&reservStock.ProductId,
&reservStock.Quantity,
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "failed to scan a reservation item", err)
}
items = append(items, &reservStock)
}
if rows.Err() != nil {
return nil, errors.WrapServiceError(errors.ErrCodeService, "error scanning item rows", rows.Err())
}
return items, nil
}
// Append items to the reservation
func (c postgresController) appendItemsToReservation(ctx context.Context, tx *pgx.Tx, reservation *pb.Reservation) (*pb.Reservation, error) {
reservedItems, err := c.getReservationItems(ctx, tx, reservation.Id)
if err != nil {
return nil, err
}
// Add the items to the reservation protobuf
reservation.ReservedStock = reservedItems
return reservation, nil
}
// Scan a postgres row to a protobuf object
func scanRowToProductStock(row pgx.Row) (*pb.ProductStock, error) {
var stock pb.ProductStock
err := row.Scan(
&stock.ProductId,
&stock.Quantity,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, errors.WrapServiceError(errors.ErrCodeNotFound, "stock not found", err)
} else {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "something went wrong scanning object", err)
}
}
return &stock, nil
}
// Scan a postgres row to a protobuf object
func scanRowToReservation(row pgx.Row) (*pb.Reservation, error) {
var reservation pb.Reservation
// Temporary variables that require conversion
var tmpCreatedAt pgtype.Timestamp
err := row.Scan(
&reservation.Id,
&reservation.OrderId,
&tmpCreatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, errors.WrapServiceError(errors.ErrCodeNotFound, "reservation not found", err)
} else {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "something went wrong scanning object", err)
}
}
// convert postgres timestamps to unix format
if tmpCreatedAt.Valid {
reservation.CreatedAt = tmpCreatedAt.Time.Unix()
}
return &reservation, nil
}

View File

@@ -0,0 +1,141 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package warehouse
import (
"github.com/hexolan/stocklet/internal/pkg/messaging"
eventspb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/warehouse/v1"
)
type EventOrderMetadata struct {
CustomerId string
ItemsPrice float32
TotalPrice float32
}
func PrepareStockCreatedEvent(productStock *pb.ProductStock) ([]byte, string, error) {
topic := messaging.Warehouse_Stock_Created_Topic
event := &eventspb.StockCreatedEvent{
Revision: 1,
ProductId: productStock.ProductId,
Quantity: productStock.Quantity,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareStockAddedEvent(productId string, amount int32, reservationId *string) ([]byte, string, error) {
topic := messaging.Warehouse_Stock_Added_Topic
event := &eventspb.StockAddedEvent{
Revision: 1,
ProductId: productId,
Amount: amount,
ReservationId: reservationId,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareStockRemovedEvent(productId string, amount int32, reservationId *string) ([]byte, string, error) {
topic := messaging.Warehouse_Stock_Removed_Topic
event := &eventspb.StockRemovedEvent{
Revision: 1,
ProductId: productId,
Amount: amount,
ReservationId: reservationId,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareStockReservationEvent_Failed(orderId string, orderMetadata EventOrderMetadata, insufficientStockProductIds []string) ([]byte, string, error) {
topic := messaging.Warehouse_Reservation_Failed_Topic
event := &eventspb.StockReservationEvent{
Revision: 1,
Type: eventspb.StockReservationEvent_TYPE_INSUFFICIENT_STOCK,
OrderId: orderId,
OrderMetadata: &eventspb.StockReservationEvent_OrderMetadata{
CustomerId: orderMetadata.CustomerId,
ItemsPrice: orderMetadata.ItemsPrice,
TotalPrice: orderMetadata.TotalPrice,
},
InsufficientStock: insufficientStockProductIds,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareStockReservationEvent_Reserved(orderId string, orderMetadata EventOrderMetadata, reservationId string, reservationStock map[string]int32) ([]byte, string, error) {
topic := messaging.Warehouse_Reservation_Reserved_Topic
event := &eventspb.StockReservationEvent{
Revision: 1,
Type: eventspb.StockReservationEvent_TYPE_STOCK_RESERVED,
OrderId: orderId,
OrderMetadata: &eventspb.StockReservationEvent_OrderMetadata{
CustomerId: orderMetadata.CustomerId,
ItemsPrice: orderMetadata.ItemsPrice,
TotalPrice: orderMetadata.TotalPrice,
},
ReservationId: reservationId,
ReservationStock: reservationStock,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareStockReservationEvent_Returned(orderId string, reservationId string, reservedStock []*pb.ReservationStock) ([]byte, string, error) {
reservationStock := make(map[string]int32)
for _, item := range reservedStock {
reservationStock[item.ProductId] = item.Quantity
}
topic := messaging.Warehouse_Reservation_Returned_Topic
event := &eventspb.StockReservationEvent{
Revision: 1,
Type: eventspb.StockReservationEvent_TYPE_STOCK_RESERVED,
OrderId: orderId,
ReservationId: reservationId,
ReservationStock: reservationStock,
}
return messaging.MarshalEvent(event, topic)
}
func PrepareStockReservationEvent_Consumed(orderId string, reservationId string, reservedStock []*pb.ReservationStock) ([]byte, string, error) {
reservationStock := make(map[string]int32)
for _, item := range reservedStock {
reservationStock[item.ProductId] = item.Quantity
}
topic := messaging.Warehouse_Reservation_Consumed_Topic
event := &eventspb.StockReservationEvent{
Revision: 1,
Type: eventspb.StockReservationEvent_TYPE_STOCK_CONSUMED,
OrderId: orderId,
ReservationId: reservationId,
ReservationStock: reservationStock,
}
return messaging.MarshalEvent(event, topic)
}

View File

@@ -0,0 +1,168 @@
// Copyright (C) 2024 Declan Teevan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package warehouse
import (
"context"
"github.com/bufbuild/protovalidate-go"
"github.com/rs/zerolog/log"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/hexolan/stocklet/internal/pkg/errors"
"github.com/hexolan/stocklet/internal/pkg/messaging"
commonpb "github.com/hexolan/stocklet/internal/pkg/protogen/common/v1"
eventpb "github.com/hexolan/stocklet/internal/pkg/protogen/events/v1"
pb "github.com/hexolan/stocklet/internal/pkg/protogen/warehouse/v1"
)
// Interface for the service
type WarehouseService struct {
pb.UnimplementedWarehouseServiceServer
store StorageController
pbVal *protovalidate.Validator
}
// Interface for database methods
// Flexibility for implementing seperate controllers for different databases (e.g. Postgres, MongoDB, etc)
type StorageController interface {
GetProductStock(ctx context.Context, productId string) (*pb.ProductStock, error)
GetReservation(ctx context.Context, reservationId string) (*pb.Reservation, error)
CreateProductStock(ctx context.Context, productId string, startingQuantity int32) error
ReserveOrderStock(ctx context.Context, orderId string, orderMetadata EventOrderMetadata, productQuantities map[string]int32) error
ReturnReservedOrderStock(ctx context.Context, orderId string) error
ConsumeReservedOrderStock(ctx context.Context, orderId string) error
}
// Interface for event consumption
// Flexibility for seperate controllers for different messaging systems (e.g. Kafka, NATS, etc)
type ConsumerController interface {
messaging.ConsumerController
Attach(svc pb.WarehouseServiceServer)
}
// Create the shipping service
func NewWarehouseService(cfg *ServiceConfig, store StorageController) *WarehouseService {
// Initialise the protobuf validator
pbVal, err := protovalidate.New()
if err != nil {
log.Panic().Err(err).Msg("failed to initialise protobuf validator")
}
// Initialise the service
return &WarehouseService{
store: store,
pbVal: pbVal,
}
}
func (svc WarehouseService) ServiceInfo(ctx context.Context, req *commonpb.ServiceInfoRequest) (*commonpb.ServiceInfoResponse, error) {
return &commonpb.ServiceInfoResponse{
Name: "warehouse",
Source: "https://github.com/hexolan/stocklet",
SourceLicense: "AGPL-3.0",
}, nil
}
func (svc WarehouseService) ViewProductStock(ctx context.Context, req *pb.ViewProductStockRequest) (*pb.ViewProductStockResponse, error) {
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// Provide the validation error to the user.
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// Get stock from db
stock, err := svc.store.GetProductStock(ctx, req.ProductId)
if err != nil {
return nil, err
}
return &pb.ViewProductStockResponse{Stock: stock}, nil
}
func (svc WarehouseService) ViewReservation(ctx context.Context, req *pb.ViewReservationRequest) (*pb.ViewReservationResponse, error) {
// Validate the request args
if err := svc.pbVal.Validate(req); err != nil {
// Provide the validation error to the user.
return nil, errors.NewServiceError(errors.ErrCodeInvalidArgument, "invalid request: "+err.Error())
}
// Get reservation from db
reservation, err := svc.store.GetReservation(ctx, req.ReservationId)
if err != nil {
return nil, err
}
return &pb.ViewReservationResponse{Reservation: reservation}, nil
}
func (svc WarehouseService) ProcessProductCreatedEvent(ctx context.Context, req *eventpb.ProductCreatedEvent) (*emptypb.Empty, error) {
err := svc.store.CreateProductStock(ctx, req.ProductId, 0)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "error processing event", err)
}
return &emptypb.Empty{}, nil
}
func (svc WarehouseService) ProcessOrderPendingEvent(ctx context.Context, req *eventpb.OrderPendingEvent) (*emptypb.Empty, error) {
err := svc.store.ReserveOrderStock(
ctx,
req.OrderId,
EventOrderMetadata{
CustomerId: req.CustomerId,
ItemsPrice: req.ItemsPrice,
TotalPrice: req.TotalPrice,
},
req.ItemQuantities,
)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "error processing event", err)
}
return &emptypb.Empty{}, nil
}
func (svc WarehouseService) ProcessShipmentAllocationEvent(ctx context.Context, req *eventpb.ShipmentAllocationEvent) (*emptypb.Empty, error) {
if req.Type == eventpb.ShipmentAllocationEvent_TYPE_FAILED {
err := svc.store.ReturnReservedOrderStock(ctx, req.OrderId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "error processing event", err)
}
}
return &emptypb.Empty{}, nil
}
func (svc WarehouseService) ProcessPaymentProcessedEvent(ctx context.Context, req *eventpb.PaymentProcessedEvent) (*emptypb.Empty, error) {
if req.Type == eventpb.PaymentProcessedEvent_TYPE_FAILED {
err := svc.store.ReturnReservedOrderStock(ctx, req.OrderId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "error processing event", err)
}
} else if req.Type == eventpb.PaymentProcessedEvent_TYPE_SUCCESS {
err := svc.store.ConsumeReservedOrderStock(ctx, req.OrderId)
if err != nil {
return nil, errors.WrapServiceError(errors.ErrCodeExtService, "error processing event", err)
}
}
return &emptypb.Empty{}, nil
}