mirror of
https://github.com/hexolan/stocklet.git
synced 2026-03-26 19:51:17 +00:00
chore: initial commit
This commit is contained in:
39
internal/svc/auth/api/gateway.go
Normal file
39
internal/svc/auth/api/gateway.go
Normal 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
|
||||
}
|
||||
30
internal/svc/auth/api/grpc.go
Normal file
30
internal/svc/auth/api/grpc.go
Normal 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
157
internal/svc/auth/auth.go
Normal 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
156
internal/svc/auth/config.go
Normal 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
|
||||
}
|
||||
109
internal/svc/auth/controller/kafka.go
Normal file
109
internal/svc/auth/controller/kafka.go
Normal 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)
|
||||
})
|
||||
}
|
||||
97
internal/svc/auth/controller/postgres.go
Normal file
97
internal/svc/auth/controller/postgres.go
Normal 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
|
||||
}
|
||||
40
internal/svc/auth/hashing.go
Normal file
40
internal/svc/auth/hashing.go
Normal 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
56
internal/svc/auth/jwt.go
Normal 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
|
||||
}
|
||||
39
internal/svc/order/api/gateway.go
Normal file
39
internal/svc/order/api/gateway.go
Normal 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
|
||||
}
|
||||
30
internal/svc/order/api/grpc.go
Normal file
30
internal/svc/order/api/grpc.go
Normal 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
|
||||
}
|
||||
42
internal/svc/order/config.go
Normal file
42
internal/svc/order/config.go
Normal 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
|
||||
}
|
||||
179
internal/svc/order/controller/kafka.go
Normal file
179
internal/svc/order/controller/kafka.go
Normal 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)
|
||||
})
|
||||
}
|
||||
458
internal/svc/order/controller/postgres.go
Normal file
458
internal/svc/order/controller/postgres.go
Normal 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
|
||||
}
|
||||
74
internal/svc/order/event.go
Normal file
74
internal/svc/order/event.go
Normal 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
229
internal/svc/order/order.go
Normal 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
|
||||
}
|
||||
39
internal/svc/payment/api/gateway.go
Normal file
39
internal/svc/payment/api/gateway.go
Normal 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
|
||||
}
|
||||
30
internal/svc/payment/api/grpc.go
Normal file
30
internal/svc/payment/api/grpc.go
Normal 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
|
||||
}
|
||||
42
internal/svc/payment/config.go
Normal file
42
internal/svc/payment/config.go
Normal 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
|
||||
}
|
||||
140
internal/svc/payment/controller/kafka.go
Normal file
140
internal/svc/payment/controller/kafka.go
Normal 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)
|
||||
})
|
||||
}
|
||||
458
internal/svc/payment/controller/postgres.go
Normal file
458
internal/svc/payment/controller/postgres.go
Normal 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
|
||||
}
|
||||
129
internal/svc/payment/event.go
Normal file
129
internal/svc/payment/event.go
Normal 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)
|
||||
}
|
||||
132
internal/svc/payment/payment.go
Normal file
132
internal/svc/payment/payment.go
Normal 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
|
||||
}
|
||||
39
internal/svc/product/api/gateway.go
Normal file
39
internal/svc/product/api/gateway.go
Normal 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
|
||||
}
|
||||
30
internal/svc/product/api/grpc.go
Normal file
30
internal/svc/product/api/grpc.go
Normal 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
|
||||
}
|
||||
42
internal/svc/product/config.go
Normal file
42
internal/svc/product/config.go
Normal 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
|
||||
}
|
||||
114
internal/svc/product/controller/kafka.go
Normal file
114
internal/svc/product/controller/kafka.go
Normal 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)
|
||||
})
|
||||
}
|
||||
294
internal/svc/product/controller/postgres.go
Normal file
294
internal/svc/product/controller/postgres.go
Normal 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
|
||||
}
|
||||
86
internal/svc/product/event.go
Normal file
86
internal/svc/product/event.go
Normal 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)
|
||||
}
|
||||
116
internal/svc/product/product.go
Normal file
116
internal/svc/product/product.go
Normal 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
|
||||
}
|
||||
39
internal/svc/shipping/api/gateway.go
Normal file
39
internal/svc/shipping/api/gateway.go
Normal 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
|
||||
}
|
||||
30
internal/svc/shipping/api/grpc.go
Normal file
30
internal/svc/shipping/api/grpc.go
Normal 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
|
||||
}
|
||||
42
internal/svc/shipping/config.go
Normal file
42
internal/svc/shipping/config.go
Normal 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
|
||||
}
|
||||
135
internal/svc/shipping/controller/kafka.go
Normal file
135
internal/svc/shipping/controller/kafka.go
Normal 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)
|
||||
})
|
||||
}
|
||||
256
internal/svc/shipping/controller/postgres.go
Normal file
256
internal/svc/shipping/controller/postgres.go
Normal 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
|
||||
}
|
||||
97
internal/svc/shipping/event.go
Normal file
97
internal/svc/shipping/event.go
Normal 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)
|
||||
}
|
||||
147
internal/svc/shipping/shipping.go
Normal file
147
internal/svc/shipping/shipping.go
Normal 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
|
||||
}
|
||||
39
internal/svc/user/api/gateway.go
Normal file
39
internal/svc/user/api/gateway.go
Normal 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
|
||||
}
|
||||
30
internal/svc/user/api/grpc.go
Normal file
30
internal/svc/user/api/grpc.go
Normal 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
|
||||
}
|
||||
65
internal/svc/user/config.go
Normal file
65
internal/svc/user/config.go
Normal 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
|
||||
}
|
||||
247
internal/svc/user/controller/postgres.go
Normal file
247
internal/svc/user/controller/postgres.go
Normal 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
|
||||
}
|
||||
60
internal/svc/user/event.go
Normal file
60
internal/svc/user/event.go
Normal 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
111
internal/svc/user/user.go
Normal 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
|
||||
}
|
||||
39
internal/svc/warehouse/api/gateway.go
Normal file
39
internal/svc/warehouse/api/gateway.go
Normal 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
|
||||
}
|
||||
30
internal/svc/warehouse/api/grpc.go
Normal file
30
internal/svc/warehouse/api/grpc.go
Normal 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
|
||||
}
|
||||
42
internal/svc/warehouse/config.go
Normal file
42
internal/svc/warehouse/config.go
Normal 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
|
||||
}
|
||||
161
internal/svc/warehouse/controller/kafka.go
Normal file
161
internal/svc/warehouse/controller/kafka.go
Normal 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)
|
||||
})
|
||||
}
|
||||
500
internal/svc/warehouse/controller/postgres.go
Normal file
500
internal/svc/warehouse/controller/postgres.go
Normal 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
|
||||
}
|
||||
141
internal/svc/warehouse/event.go
Normal file
141
internal/svc/warehouse/event.go
Normal 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)
|
||||
}
|
||||
168
internal/svc/warehouse/warehouse.go
Normal file
168
internal/svc/warehouse/warehouse.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user