├── .gitignore ├── LICENSE ├── README.md ├── account ├── .gitignore ├── Dockerfile ├── account │ ├── account.go │ ├── interactor.go │ ├── mapper.go │ ├── repository.go │ ├── role.go │ ├── service.go │ └── status.go ├── config │ ├── config.go │ ├── dev.env │ └── prod.env ├── database │ └── database.go ├── docker-compose.yml ├── fn │ └── fn.go ├── go.mod ├── go.sum ├── main │ └── main.go └── server │ ├── interceptor.go │ └── server.go ├── authentication ├── .gitignore ├── Dockerfile ├── README.md ├── account │ ├── account.go │ ├── client.go │ ├── mapper.go │ ├── repository.go │ ├── role.go │ └── status.go ├── authentication │ ├── interactor.go │ └── service.go ├── config │ ├── config.go │ ├── dev.env │ └── prod.env ├── confirmation │ ├── client.go │ └── repository.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── main │ └── main.go ├── media │ ├── .gitkeep │ ├── ecommerce-backend-authentication-first-stage.png │ ├── ecommerce-backend-authentication-refresh-stage.png │ └── ecommerce-backend-authentication-second-stage.png ├── server │ └── server.go └── token │ ├── client.go │ ├── pair.go │ └── repository.go ├── build.sh ├── cart ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json ├── src │ ├── application │ │ └── index.ts │ ├── cart │ │ ├── CartError.ts │ │ ├── CartRepository.ts │ │ ├── CartService.ts │ │ ├── ClearCart.ts │ │ ├── DecreaseItemQuantity.ts │ │ ├── GetCart.ts │ │ ├── IncreaseItemQuantity.ts │ │ ├── Item.ts │ │ └── ItemMapper.ts │ ├── config │ │ └── Config.ts │ ├── di │ │ ├── module.ts │ │ └── types.ts │ ├── index.ts │ ├── interactor │ │ └── UseCase.ts │ ├── response │ │ ├── ResponseError.ts │ │ └── index.ts │ ├── server │ │ └── Server.ts │ └── store │ │ ├── Store.ts │ │ └── StoreError.ts └── tsconfig.json ├── catalog ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── media │ ├── .gitkeep │ └── ecommerce-backend-catalog.png ├── package-lock.json ├── package.json ├── src │ ├── amqp │ │ └── MessageQueue.ts │ ├── app │ │ └── index.ts │ ├── catalog │ │ ├── AddCatalogItem.ts │ │ ├── CatalogError.ts │ │ ├── CatalogItem.ts │ │ ├── CatalogItemMapper.ts │ │ ├── CatalogRepository.ts │ │ ├── CatalogService.ts │ │ ├── GetCatalogItemById.ts │ │ ├── GetCatalogItemsByTags.ts │ │ ├── RemoveCatalogItem.ts │ │ ├── SortType.ts │ │ └── UpdateCatalogItem.ts │ ├── config │ │ └── Config.ts │ ├── database │ │ └── Database.ts │ ├── di │ │ ├── module.ts │ │ └── types.ts │ ├── index.ts │ ├── interactor │ │ └── UseCase.ts │ ├── response │ │ ├── ResponseError.ts │ │ └── index.ts │ └── server │ │ └── Server.ts └── tsconfig.json ├── category ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── media │ ├── .gitkeep │ └── ecommerce-backend-category.png ├── package-lock.json ├── package.json ├── src │ ├── app │ │ └── index.ts │ ├── category │ │ ├── AddCategory.ts │ │ ├── Category.ts │ │ ├── CategoryError.ts │ │ ├── CategoryMapper.ts │ │ ├── CategoryRepository.ts │ │ ├── CategoryService.ts │ │ ├── GetCategories.ts │ │ ├── GetCategoriesByTags.ts │ │ ├── GetCategoryById.ts │ │ ├── RemoveCategory.ts │ │ └── UpdateCategory.ts │ ├── config │ │ └── Config.ts │ ├── database │ │ └── Database.ts │ ├── di │ │ ├── module.ts │ │ └── types.ts │ ├── index.ts │ ├── interactor │ │ └── UseCase.ts │ ├── response │ │ ├── ResponseError.ts │ │ └── index.ts │ └── server │ │ └── Server.ts └── tsconfig.json ├── confirmation ├── .gitignore ├── Dockerfile ├── config │ ├── config.go │ ├── dev.env │ └── prod.env ├── confirmation │ ├── error.go │ ├── interactor.go │ ├── repository.go │ ├── repository_test.go │ └── service.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── main │ └── main.go ├── server │ ├── interceptor.go │ └── server.go └── store │ └── store.go ├── delivery ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json ├── src │ ├── app │ │ └── index.ts │ ├── config │ │ └── Config.ts │ ├── database │ │ └── Database.ts │ ├── delivery │ │ ├── CancelDelivery.ts │ │ ├── CompleteDelivery.ts │ │ ├── Delivery.ts │ │ ├── DeliveryError.ts │ │ ├── DeliveryItem.ts │ │ ├── DeliveryItemMapper.ts │ │ ├── DeliveryMapper.ts │ │ ├── DeliveryRepository.ts │ │ ├── DeliveryService.ts │ │ ├── DeliveryStatus.ts │ │ ├── GetDeliveriesByCourierId.ts │ │ ├── GetDeliveriesByOrderId.ts │ │ ├── GetDeliveryById.ts │ │ ├── RemoveDelivery.ts │ │ ├── StartDelivery.ts │ │ └── UpdateDelivery.ts │ ├── di │ │ ├── module.ts │ │ └── types.ts │ ├── index.ts │ ├── interactor │ │ └── UseCase.ts │ ├── response │ │ ├── ResponseError.ts │ │ └── index.ts │ └── server │ │ └── Server.ts └── tsconfig.json ├── docker-compose.sh ├── gateway ├── .gitignore ├── Dockerfile ├── authentication │ ├── client.go │ └── service.go ├── cart │ └── service.go ├── catalog │ └── service.go ├── category │ └── service.go ├── config │ ├── config.go │ ├── dev.env │ └── prod.env ├── delivery │ └── service.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── main │ └── main.go ├── order │ └── service.go ├── profile │ └── service.go ├── promo │ └── service.go ├── search │ └── search.go └── server │ ├── interceptor.go │ └── server.go ├── media ├── .gitkeep └── ecommerce-backend-overview.png ├── order ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json ├── src │ ├── app │ │ └── index.ts │ ├── config │ │ └── Config.ts │ ├── database │ │ └── Database.ts │ ├── di │ │ ├── module.ts │ │ └── types.ts │ ├── index.ts │ ├── interactor │ │ └── UseCase.ts │ ├── order │ │ ├── CreateOrder.ts │ │ ├── DeleteOrder.ts │ │ ├── GetCustomerOrders.ts │ │ ├── GetOrderById.ts │ │ ├── Order.ts │ │ ├── OrderError.ts │ │ ├── OrderMapper.ts │ │ ├── OrderRepository.ts │ │ ├── OrderService.ts │ │ ├── OrderStatus.ts │ │ ├── OrderedItem.ts │ │ ├── OrderedItemMapper.ts │ │ └── UpdateOrder.ts │ ├── response │ │ ├── ResponseError.ts │ │ └── index.ts │ └── server │ │ └── Server.ts └── tsconfig.json ├── profile ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json ├── src │ ├── app │ │ └── index.ts │ ├── config │ │ └── Config.ts │ ├── database │ │ ├── Database.ts │ │ └── DatabaseError.ts │ ├── di │ │ ├── module.ts │ │ └── types.ts │ ├── index.ts │ ├── interactor │ │ └── UseCase.ts │ ├── profile │ │ ├── CreateProfile.ts │ │ ├── GetProfileById.ts │ │ ├── Profile.ts │ │ ├── ProfileError.ts │ │ ├── ProfileMapper.ts │ │ ├── ProfileRepository.ts │ │ ├── ProfileService.ts │ │ ├── RemoveProfile.ts │ │ └── UpdateProfile.ts │ ├── response │ │ ├── ResponseError.ts │ │ └── index.ts │ └── server │ │ └── Server.ts └── tsconfig.json ├── promo ├── .env ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json ├── src │ ├── application │ │ └── index.ts │ ├── config │ │ └── Config.ts │ ├── di │ │ ├── module.ts │ │ └── types.ts │ ├── index.ts │ ├── interactor │ │ └── UseCase.ts │ ├── promo │ │ ├── GetPromo.ts │ │ ├── InsertPromo.ts │ │ ├── Promo.ts │ │ ├── PromoError.ts │ │ ├── PromoMapper.ts │ │ ├── PromoRepository.ts │ │ ├── PromoService.ts │ │ ├── PromoServiceServer.ts │ │ └── RemovePromo.ts │ ├── response │ │ ├── ResponseError.ts │ │ └── index.ts │ ├── server │ │ └── Server.ts │ └── store │ │ ├── Store.ts │ │ └── StoreError.ts └── tsconfig.json ├── proto ├── account.proto ├── authentication.proto ├── cart.proto ├── catalog.proto ├── category.proto ├── confirmation.proto ├── delivery.proto ├── order.proto ├── profile.proto ├── promo.proto ├── search.proto └── token.proto ├── search ├── .gitignore ├── Dockerfile ├── amqp │ ├── consumer.go │ └── queue.go ├── config │ ├── config.go │ ├── dev.env │ └── prod.env ├── docker-compose.yml ├── elastic │ └── client.go ├── fn │ └── fn.go ├── go.mod ├── go.sum ├── main │ └── main.go ├── search │ ├── dispatcher.go │ ├── interactor.go │ ├── item.go │ ├── mapper.go │ ├── repository.go │ └── service.go └── server │ └── server.go └── token ├── .gitignore ├── Dockerfile ├── config ├── config.go ├── dev.env └── prod.env ├── docker-compose.yml ├── go.mod ├── go.sum ├── main └── main.go ├── server ├── interceptor.go └── server.go ├── store └── store.go └── token ├── claims.go ├── interactor.go ├── pair.go ├── repository.go ├── repository_test.go └── service.go /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 numq 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /account/.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /generated/ -------------------------------------------------------------------------------- /account/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine 2 | WORKDIR /build 3 | COPY . . 4 | RUN apk update && apk add protoc 5 | CMD ["rm","-r","generated"] 6 | CMD ["mkdir","-p","generated"] 7 | CMD ["protoc", "--go_out=generated", "--go-grpc_out=generated", "--proto_path=proto", "proto/*.proto"] 8 | RUN go get -d -v ./... 9 | RUN go build -o target ./main 10 | 11 | FROM alpine:latest 12 | COPY --from=0 build/config/prod.env . 13 | COPY --from=0 build/target . 14 | CMD ["./target","-production"] -------------------------------------------------------------------------------- /account/account/account.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | type Account struct { 4 | Id string `bson:"_id"` 5 | PhoneNumber string `bson:"phoneNumber"` 6 | Role Role `bson:"role,omitempty"` 7 | Status Status `bson:"status,omitempty"` 8 | CreatedAt int64 `bson:"createdAt,omitempty"` 9 | } 10 | -------------------------------------------------------------------------------- /account/account/mapper.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import pb "account/generated" 4 | 5 | type Mapper interface { 6 | MessageToEntity(message *pb.Account) *Account 7 | EntityToMessage(entity *Account) *pb.Account 8 | } 9 | 10 | type MapperImpl struct { 11 | } 12 | 13 | func NewMapper() Mapper { 14 | return &MapperImpl{} 15 | } 16 | 17 | func (m *MapperImpl) MessageToEntity(message *pb.Account) *Account { 18 | return &Account{ 19 | Id: message.GetId(), 20 | PhoneNumber: message.GetPhoneNumber(), 21 | Role: Role(message.GetRole()), 22 | Status: Status(message.GetStatus()), 23 | CreatedAt: message.GetCreatedAt(), 24 | } 25 | } 26 | 27 | func (m *MapperImpl) EntityToMessage(entity *Account) *pb.Account { 28 | return &pb.Account{ 29 | Id: entity.Id, 30 | PhoneNumber: entity.PhoneNumber, 31 | Role: pb.Role(entity.Role), 32 | Status: pb.Status(entity.Status), 33 | CreatedAt: entity.CreatedAt, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /account/account/role.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | type Role int 4 | 5 | const ( 6 | Root Role = iota 7 | Staff 8 | Courier 9 | Customer 10 | ) 11 | 12 | func (r Role) String() string { 13 | return [...]string{"ROOT", "STAFF", "COURIER", "CUSTOMER"}[r] 14 | } 15 | -------------------------------------------------------------------------------- /account/account/status.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | type Status int 4 | 5 | const ( 6 | PendingConfirmation Status = iota 7 | Active 8 | Suspended 9 | ) 10 | 11 | func (s Status) String() string { 12 | return [...]string{"PENDING_CONFIRMATION", "ACTIVE", "SUSPENDED"}[s] 13 | } 14 | -------------------------------------------------------------------------------- /account/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | "log" 6 | ) 7 | 8 | type Config struct { 9 | ServiceName string `mapstructure:"SERVICE_NAME"` 10 | ServerAddress string `mapstructure:"SERVER_ADDRESS"` 11 | MongoHostname string `mapstructure:"MONGO_HOSTNAME"` 12 | MongoPort string `mapstructure:"MONGO_PORT"` 13 | DatabaseName string `mapstructure:"DATABASE_NAME"` 14 | CollectionItems string `mapstructure:"COLLECTION_ITEMS"` 15 | ApiKey string `mapstructure:"API_KEY"` 16 | } 17 | 18 | func LoadConfig(name string) (config Config, err error) { 19 | viper.AddConfigPath(".") 20 | viper.AddConfigPath("./config") 21 | viper.SetConfigType("env") 22 | viper.SetConfigName(name) 23 | if err = viper.ReadInConfig(); err != nil { 24 | log.Fatal(err) 25 | } 26 | if err = viper.Unmarshal(&config); err != nil { 27 | log.Fatal(err) 28 | } 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /account/config/dev.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=account 2 | SERVER_ADDRESS=0.0.0.0:8082 3 | MONGO_HOSTNAME=0.0.0.0 4 | MONGO_PORT=27017 5 | DATABASE_NAME=account 6 | COLLECTION_ITEMS=items 7 | API_KEY=aW52b2x2ZWRzcHJpbmdjb25zb25hbnRzaGFsbG1lbWJlcmJ5bW9ua2V5ZXhlcmNpc2U= -------------------------------------------------------------------------------- /account/config/prod.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=account 2 | SERVER_ADDRESS=0.0.0.0:8082 3 | MONGO_HOSTNAME=mongodb 4 | MONGO_PORT=27017 5 | DATABASE_NAME=account 6 | COLLECTION_ITEMS=items 7 | API_KEY=aW52b2x2ZWRzcHJpbmdjb25zb25hbnRzaGFsbG1lbWJlcmJ5bW9ua2V5ZXhlcmNpc2U= -------------------------------------------------------------------------------- /account/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "go.mongodb.org/mongo-driver/mongo" 7 | "go.mongodb.org/mongo-driver/mongo/options" 8 | "go.mongodb.org/mongo-driver/mongo/readpref" 9 | "log" 10 | ) 11 | 12 | type Database struct { 13 | context context.Context 14 | address string 15 | client *mongo.Client 16 | db *mongo.Database 17 | } 18 | 19 | func Connect(ctx context.Context, address string) *Database { 20 | client, err := mongo.Connect(ctx, options.Client().ApplyURI(address)) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | if err := client.Ping(ctx, readpref.Primary()); err != nil { 25 | log.Fatal(err) 26 | } 27 | fmt.Printf("Connected to database: %s\n", address) 28 | return &Database{ 29 | context: ctx, 30 | address: address, 31 | client: client, 32 | } 33 | } 34 | 35 | func (d *Database) Disconnect() { 36 | if err := d.client.Disconnect(d.context); err != nil { 37 | log.Fatal(err) 38 | } 39 | fmt.Printf("Disconnected to database: %s\n", d.address) 40 | } 41 | 42 | func (d *Database) Collection(dbName string, collectionName string) *mongo.Collection { 43 | return d.client.Database(dbName).Collection(collectionName) 44 | } 45 | -------------------------------------------------------------------------------- /account/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | account: 4 | build: . 5 | depends_on: 6 | - account_mongo 7 | volumes: 8 | - ./:/data 9 | restart: on-failure 10 | ports: 11 | - '8082:8082' 12 | account_mongo: 13 | image: mongo:latest 14 | restart: on-failure 15 | ports: 16 | - '27017:27017' -------------------------------------------------------------------------------- /account/fn/fn.go: -------------------------------------------------------------------------------- 1 | package fn 2 | 3 | func Map[T any, R any](in []*T, f func(*T) *R) (out []*R) { 4 | for _, i := range in { 5 | out = append(out, f(i)) 6 | } 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /account/go.mod: -------------------------------------------------------------------------------- 1 | module account 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/spf13/viper v1.14.0 7 | go.mongodb.org/mongo-driver v1.11.1 8 | google.golang.org/grpc v1.51.0 9 | google.golang.org/protobuf v1.28.1 10 | ) 11 | 12 | require ( 13 | github.com/fsnotify/fsnotify v1.6.0 // indirect 14 | github.com/golang/protobuf v1.5.2 // indirect 15 | github.com/golang/snappy v0.0.1 // indirect 16 | github.com/hashicorp/hcl v1.0.0 // indirect 17 | github.com/klauspost/compress v1.13.6 // indirect 18 | github.com/magiconair/properties v1.8.6 // indirect 19 | github.com/mitchellh/mapstructure v1.5.0 // indirect 20 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect 21 | github.com/pelletier/go-toml v1.9.5 // indirect 22 | github.com/pelletier/go-toml/v2 v2.0.5 // indirect 23 | github.com/pkg/errors v0.9.1 // indirect 24 | github.com/spf13/afero v1.9.2 // indirect 25 | github.com/spf13/cast v1.5.0 // indirect 26 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 27 | github.com/spf13/pflag v1.0.5 // indirect 28 | github.com/subosito/gotenv v1.4.1 // indirect 29 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 30 | github.com/xdg-go/scram v1.1.1 // indirect 31 | github.com/xdg-go/stringprep v1.0.3 // indirect 32 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect 33 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect 34 | golang.org/x/net v0.2.0 // indirect 35 | golang.org/x/sync v0.1.0 // indirect 36 | golang.org/x/sys v0.2.0 // indirect 37 | golang.org/x/text v0.4.0 // indirect 38 | google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e // indirect 39 | gopkg.in/ini.v1 v1.67.0 // indirect 40 | gopkg.in/yaml.v2 v2.4.0 // indirect 41 | gopkg.in/yaml.v3 v3.0.1 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /account/main/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "account/account" 5 | "account/config" 6 | "account/database" 7 | pb "account/generated" 8 | "account/server" 9 | "context" 10 | "flag" 11 | "fmt" 12 | "google.golang.org/grpc" 13 | "google.golang.org/grpc/codes" 14 | "google.golang.org/grpc/status" 15 | "log" 16 | ) 17 | 18 | func main() { 19 | productionMode := flag.Bool("production", false, "enable production mode") 20 | flag.Parse() 21 | cfgName := func() string { 22 | if *productionMode { 23 | return "prod" 24 | } 25 | return "dev" 26 | }() 27 | cfg, err := config.LoadConfig(cfgName) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | db := database.Connect(context.Background(), fmt.Sprintf("mongodb://%s:%s", cfg.MongoHostname, cfg.MongoPort)) 33 | defer db.Disconnect() 34 | 35 | accountRepository := account.NewRepository(db.Collection(cfg.DatabaseName, cfg.CollectionItems)) 36 | accountUseCase := account.NewUseCase(accountRepository) 37 | accountService := account.NewService(accountUseCase, account.NewMapper()) 38 | authInterceptor := server.NewInterceptor("Authorization", func(ctx context.Context, header string) error { 39 | if header != cfg.ApiKey { 40 | return status.Errorf(codes.Unauthenticated, "Invalid API key") 41 | } 42 | return nil 43 | }) 44 | 45 | grpcServer := server.Server{Address: cfg.ServerAddress} 46 | grpcServer.Launch(func(server *grpc.Server) { 47 | pb.RegisterAccountServiceServer(server, accountService) 48 | }, authInterceptor) 49 | } 50 | -------------------------------------------------------------------------------- /account/server/interceptor.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "google.golang.org/grpc" 6 | "google.golang.org/grpc/codes" 7 | "google.golang.org/grpc/metadata" 8 | "google.golang.org/grpc/status" 9 | ) 10 | 11 | func NewInterceptor(name string, callback func(ctx context.Context, header string) error) grpc.ServerOption { 12 | return grpc.UnaryInterceptor(func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { 13 | md, ok := metadata.FromIncomingContext(ctx) 14 | if !ok { 15 | return nil, status.Errorf(codes.InvalidArgument, "Error reading metadata") 16 | } 17 | header := md.Get(name) 18 | if len(header) < 1 { 19 | return nil, status.Errorf(codes.InvalidArgument, "Error reading header") 20 | } 21 | if err := callback(ctx, header[0]); err != nil { 22 | return nil, status.Errorf(codes.Internal, "Error processing header") 23 | } 24 | return handler(ctx, req) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /account/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "google.golang.org/grpc" 5 | "log" 6 | "net" 7 | ) 8 | 9 | type Server struct { 10 | Address string 11 | } 12 | 13 | func (s *Server) Launch(bind func(*grpc.Server), opts ...grpc.ServerOption) { 14 | server := grpc.NewServer(opts...) 15 | bind(server) 16 | listener, err := net.Listen("tcp", s.Address) 17 | if err != nil { 18 | log.Fatal(err) 19 | } else { 20 | log.Printf("Server started on: %s", s.Address) 21 | } 22 | if err = server.Serve(listener); err != nil { 23 | log.Fatal(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /authentication/.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /generated/ -------------------------------------------------------------------------------- /authentication/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine 2 | WORKDIR /build 3 | COPY . . 4 | RUN apk update && apk add protoc 5 | CMD ["rm","-r","generated"] 6 | CMD ["mkdir","-p","generated"] 7 | CMD ["protoc", "--go_out=generated", "--go-grpc_out=generated", "--proto_path=proto", "proto/*.proto"] 8 | RUN go get -d -v ./... 9 | RUN go build -o target ./main 10 | 11 | FROM alpine:latest 12 | COPY --from=0 build/config/prod.env . 13 | COPY --from=0 build/target . 14 | CMD ["./target","-production"] -------------------------------------------------------------------------------- /authentication/README.md: -------------------------------------------------------------------------------- 1 | # Authentication microservice 2 | 3 | OTP(one time password) based JWT(access & refresh tokens) authentication 4 | 5 | [Back to overview]("https://github.com/numq/ecommerce-backend") 6 | 7 | ## Tech: 8 | 9 | - **Go** 10 | - **gRPC** 11 | - **Protobuf** 12 | - **Viper** 13 | 14 | ## Authentication: 15 | 16 | ### First stage 17 | 18 | ![First stage](./media/ecommerce-backend-authentication-first-stage.png) 19 | 20 | ### Second stage 21 | 22 | ![Second stage](./media/ecommerce-backend-authentication-second-stage.png) 23 | 24 | ## Refresh tokens: 25 | 26 | ![Refresh stage](./media/ecommerce-backend-authentication-refresh-stage.png) -------------------------------------------------------------------------------- /authentication/account/account.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | type Account struct { 4 | Id string 5 | PhoneNumber string 6 | Role Role 7 | Status Status 8 | CreatedAt int64 9 | } 10 | -------------------------------------------------------------------------------- /authentication/account/client.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | pb "authentication/generated" 5 | "google.golang.org/grpc" 6 | "google.golang.org/grpc/credentials/insecure" 7 | "log" 8 | ) 9 | 10 | func NewClient(address string) pb.AccountServiceClient { 11 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 12 | if err != nil { 13 | log.Fatal(err) 14 | } 15 | return pb.NewAccountServiceClient(connection) 16 | } 17 | -------------------------------------------------------------------------------- /authentication/account/mapper.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import pb "authentication/generated" 4 | 5 | type Mapper interface { 6 | MessageToEntity(message *pb.Account) *Account 7 | EntityToMessage(entity *Account) *pb.Account 8 | } 9 | 10 | type MapperImpl struct { 11 | } 12 | 13 | func NewMapper() Mapper { 14 | return &MapperImpl{} 15 | } 16 | 17 | func (m *MapperImpl) MessageToEntity(message *pb.Account) *Account { 18 | return &Account{ 19 | Id: message.GetId(), 20 | PhoneNumber: message.GetPhoneNumber(), 21 | Role: Role(message.GetRole()), 22 | Status: Status(message.GetStatus()), 23 | CreatedAt: message.GetCreatedAt(), 24 | } 25 | } 26 | 27 | func (m *MapperImpl) EntityToMessage(entity *Account) *pb.Account { 28 | return &pb.Account{ 29 | Id: entity.Id, 30 | PhoneNumber: entity.PhoneNumber, 31 | Role: pb.Role(entity.Role), 32 | Status: pb.Status(entity.Status), 33 | CreatedAt: entity.CreatedAt, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /authentication/account/role.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | type Role int 4 | 5 | const ( 6 | Root Role = iota 7 | Staff 8 | Courier 9 | Customer 10 | ) 11 | 12 | func (r Role) String() string { 13 | return [...]string{"ROOT", "STAFF", "COURIER", "CUSTOMER"}[r] 14 | } 15 | -------------------------------------------------------------------------------- /authentication/account/status.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | type Status int 4 | 5 | const ( 6 | PendingConfirmation Status = iota 7 | Active 8 | Suspended 9 | ) 10 | 11 | func (s Status) String() string { 12 | return [...]string{"PENDING_CONFIRMATION", "ACTIVE", "SUSPENDED"}[s] 13 | } 14 | -------------------------------------------------------------------------------- /authentication/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | "log" 6 | ) 7 | 8 | type Config struct { 9 | ServiceName string `mapstructure:"SERVICE_NAME"` 10 | ServerAddress string `mapstructure:"SERVER_ADDRESS"` 11 | RedisHostname string `mapstructure:"REDIS_HOSTNAME"` 12 | RedisPort string `mapstructure:"REDIS_PORT"` 13 | MongoHostname string `mapstructure:"MONGO_HOSTNAME"` 14 | MongoPort string `mapstructure:"MONGO_PORT"` 15 | DatabaseName string `mapstructure:"DATABASE_NAME"` 16 | CollectionItems string `mapstructure:"COLLECTION_ITEMS"` 17 | TokenAddress string `mapstructure:"TOKEN_ADDRESS"` 18 | AccountAddress string `mapstructure:"ACCOUNT_ADDRESS"` 19 | ConfirmationAddress string `mapstructure:"CONFIRMATION_ADDRESS"` 20 | ApiKey string `mapstructure:"API_KEY"` 21 | } 22 | 23 | func LoadConfig(name string) (config Config, err error) { 24 | viper.AddConfigPath(".") 25 | viper.AddConfigPath("./config") 26 | viper.SetConfigType("env") 27 | viper.SetConfigName(name) 28 | if err = viper.ReadInConfig(); err != nil { 29 | log.Fatal(err) 30 | } 31 | if err = viper.Unmarshal(&config); err != nil { 32 | log.Fatal(err) 33 | } 34 | return 35 | } 36 | -------------------------------------------------------------------------------- /authentication/config/dev.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=authentication 2 | SERVER_ADDRESS=0.0.0.0:8080 3 | REDIS_HOSTNAME=0.0.0.0 4 | REDIS_PORT=6379 5 | MONGO_HOSTNAME=0.0.0.0 6 | MONGO_PORT=27017 7 | DATABASE_NAME=authentication 8 | COLLECTION_ITEMS=items 9 | TOKEN_ADDRESS=0.0.0.0:8081 10 | ACCOUNT_ADDRESS=0.0.0.0:8082 11 | CONFIRMATION_ADDRESS=0.0.0.0:8083 12 | API_KEY=aW52b2x2ZWRzcHJpbmdjb25zb25hbnRzaGFsbG1lbWJlcmJ5bW9ua2V5ZXhlcmNpc2U= -------------------------------------------------------------------------------- /authentication/config/prod.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=authentication 2 | SERVER_ADDRESS=0.0.0.0:8080 3 | REDIS_HOSTNAME=redis 4 | REDIS_PORT=6379 5 | MONGO_HOSTNAME=mongodb 6 | MONGO_PORT=27017 7 | DATABASE_NAME=authentication 8 | COLLECTION_ITEMS=items 9 | TOKEN_ADDRESS=0.0.0.0:8081 10 | ACCOUNT_ADDRESS=0.0.0.0:8082 11 | CONFIRMATION_ADDRESS=0.0.0.0:8083 12 | API_KEY=aW52b2x2ZWRzcHJpbmdjb25zb25hbnRzaGFsbG1lbWJlcmJ5bW9ua2V5ZXhlcmNpc2U= -------------------------------------------------------------------------------- /authentication/confirmation/client.go: -------------------------------------------------------------------------------- 1 | package confirmation 2 | 3 | import ( 4 | pb "authentication/generated" 5 | "google.golang.org/grpc" 6 | "google.golang.org/grpc/credentials/insecure" 7 | "log" 8 | ) 9 | 10 | func NewClient(address string) pb.ConfirmationServiceClient { 11 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 12 | if err != nil { 13 | log.Fatal(err) 14 | } 15 | return pb.NewConfirmationServiceClient(connection) 16 | } 17 | -------------------------------------------------------------------------------- /authentication/confirmation/repository.go: -------------------------------------------------------------------------------- 1 | package confirmation 2 | 3 | import ( 4 | pb "authentication/generated" 5 | "context" 6 | ) 7 | 8 | type Repository interface { 9 | SendPhoneNumberConfirmation(ctx context.Context, phoneNumber string) (*int64, error) 10 | VerifyPhoneNumberConfirmation(ctx context.Context, phoneNumber string, confirmationCode string) (*string, error) 11 | } 12 | 13 | type RepositoryImpl struct { 14 | client pb.ConfirmationServiceClient 15 | } 16 | 17 | func NewRepository(client pb.ConfirmationServiceClient) Repository { 18 | return &RepositoryImpl{client: client} 19 | } 20 | 21 | func (r *RepositoryImpl) SendPhoneNumberConfirmation(ctx context.Context, phoneNumber string) (*int64, error) { 22 | response, err := r.client.SendPhoneNumberConfirmation(ctx, &pb.SendPhoneNumberConfirmationRequest{PhoneNumber: phoneNumber}) 23 | if err != nil { 24 | return nil, err 25 | } 26 | return &response.RetryAt, err 27 | } 28 | 29 | func (r *RepositoryImpl) VerifyPhoneNumberConfirmation(ctx context.Context, phoneNumber string, confirmationCode string) (*string, error) { 30 | response, err := r.client.VerifyPhoneNumberConfirmation(ctx, &pb.VerifyPhoneNumberConfirmationRequest{PhoneNumber: phoneNumber, ConfirmationCode: confirmationCode}) 31 | if err != nil { 32 | return nil, err 33 | } 34 | return &response.PhoneNumber, err 35 | } 36 | -------------------------------------------------------------------------------- /authentication/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | authentication: 4 | build: . 5 | volumes: 6 | - ./:/data 7 | restart: on-failure 8 | ports: 9 | - '8080:8080' -------------------------------------------------------------------------------- /authentication/go.mod: -------------------------------------------------------------------------------- 1 | module authentication 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/spf13/viper v1.14.0 7 | google.golang.org/grpc v1.50.1 8 | google.golang.org/protobuf v1.28.1 9 | ) 10 | 11 | require ( 12 | github.com/fsnotify/fsnotify v1.6.0 // indirect 13 | github.com/golang/protobuf v1.5.2 // indirect 14 | github.com/hashicorp/hcl v1.0.0 // indirect 15 | github.com/magiconair/properties v1.8.6 // indirect 16 | github.com/mitchellh/mapstructure v1.5.0 // indirect 17 | github.com/pelletier/go-toml v1.9.5 // indirect 18 | github.com/pelletier/go-toml/v2 v2.0.5 // indirect 19 | github.com/spf13/afero v1.9.2 // indirect 20 | github.com/spf13/cast v1.5.0 // indirect 21 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 22 | github.com/spf13/pflag v1.0.5 // indirect 23 | github.com/subosito/gotenv v1.4.1 // indirect 24 | golang.org/x/net v0.2.0 // indirect 25 | golang.org/x/sys v0.2.0 // indirect 26 | golang.org/x/text v0.4.0 // indirect 27 | google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e // indirect 28 | gopkg.in/ini.v1 v1.67.0 // indirect 29 | gopkg.in/yaml.v2 v2.4.0 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /authentication/main/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "authentication/account" 5 | "authentication/authentication" 6 | "authentication/config" 7 | "authentication/confirmation" 8 | pb "authentication/generated" 9 | "authentication/server" 10 | "authentication/token" 11 | "flag" 12 | "google.golang.org/grpc" 13 | "google.golang.org/grpc/metadata" 14 | "log" 15 | ) 16 | 17 | func main() { 18 | productionMode := flag.Bool("production", false, "enable production mode") 19 | flag.Parse() 20 | cfgName := func() string { 21 | if *productionMode { 22 | return "prod" 23 | } 24 | return "dev" 25 | }() 26 | cfg, err := config.LoadConfig(cfgName) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | 31 | tokenClient := token.NewClient(cfg.TokenAddress) 32 | tokenRepository := token.NewRepository(tokenClient) 33 | 34 | accountClient := account.NewClient(cfg.AccountAddress) 35 | accountRepository := account.NewRepository(accountClient, account.NewMapper()) 36 | 37 | confirmationClient := confirmation.NewClient(cfg.ConfirmationAddress) 38 | confirmationRepository := confirmation.NewRepository(confirmationClient) 39 | 40 | authenticationUseCase := authentication.NewUseCase(accountRepository, confirmationRepository, tokenRepository) 41 | 42 | md := metadata.New(map[string]string{"Authorization": cfg.ApiKey}) 43 | authenticationService := authentication.NewService(authenticationUseCase, md) 44 | grpcServer := server.Server{Address: cfg.ServerAddress} 45 | grpcServer.Launch(func(server *grpc.Server) { 46 | pb.RegisterAuthenticationServiceServer(server, authenticationService) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /authentication/media/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numq/ecommerce-backend/62cda50e1cc9a70f556687f2091dfcaebf1ce8e7/authentication/media/.gitkeep -------------------------------------------------------------------------------- /authentication/media/ecommerce-backend-authentication-first-stage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numq/ecommerce-backend/62cda50e1cc9a70f556687f2091dfcaebf1ce8e7/authentication/media/ecommerce-backend-authentication-first-stage.png -------------------------------------------------------------------------------- /authentication/media/ecommerce-backend-authentication-refresh-stage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numq/ecommerce-backend/62cda50e1cc9a70f556687f2091dfcaebf1ce8e7/authentication/media/ecommerce-backend-authentication-refresh-stage.png -------------------------------------------------------------------------------- /authentication/media/ecommerce-backend-authentication-second-stage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numq/ecommerce-backend/62cda50e1cc9a70f556687f2091dfcaebf1ce8e7/authentication/media/ecommerce-backend-authentication-second-stage.png -------------------------------------------------------------------------------- /authentication/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "google.golang.org/grpc" 5 | "log" 6 | "net" 7 | ) 8 | 9 | type Server struct { 10 | Address string 11 | } 12 | 13 | func (s *Server) Launch(bind func(*grpc.Server), opts ...grpc.ServerOption) { 14 | server := grpc.NewServer(opts...) 15 | bind(server) 16 | listener, err := net.Listen("tcp", s.Address) 17 | if err != nil { 18 | log.Fatal(err) 19 | } else { 20 | log.Printf("Server started on: %s", s.Address) 21 | } 22 | if err = server.Serve(listener); err != nil { 23 | log.Fatal(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /authentication/token/client.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | pb "authentication/generated" 5 | "google.golang.org/grpc" 6 | "google.golang.org/grpc/credentials/insecure" 7 | "log" 8 | ) 9 | 10 | func NewClient(address string) pb.TokenServiceClient { 11 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 12 | if err != nil { 13 | log.Fatal(err) 14 | } 15 | return pb.NewTokenServiceClient(connection) 16 | } 17 | -------------------------------------------------------------------------------- /authentication/token/pair.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | type Pair struct { 4 | AccessToken string 5 | RefreshToken string 6 | } 7 | -------------------------------------------------------------------------------- /authentication/token/repository.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | pb "authentication/generated" 5 | "context" 6 | ) 7 | 8 | type Repository interface { 9 | GenerateToken(ctx context.Context, id string) (*Pair, error) 10 | VerifyToken(ctx context.Context, token string) (*string, error) 11 | RevokeToken(ctx context.Context, token string) (*string, error) 12 | } 13 | 14 | type RepositoryImpl struct { 15 | client pb.TokenServiceClient 16 | } 17 | 18 | func NewRepository(client pb.TokenServiceClient) Repository { 19 | return &RepositoryImpl{client: client} 20 | } 21 | 22 | func (r *RepositoryImpl) GenerateToken(ctx context.Context, id string) (*Pair, error) { 23 | response, err := r.client.GenerateToken(ctx, &pb.GenerateTokenRequest{Id: id}) 24 | if err != nil { 25 | return nil, err 26 | } 27 | return &Pair{AccessToken: response.GetAccessToken(), RefreshToken: response.GetRefreshToken()}, err 28 | } 29 | 30 | func (r *RepositoryImpl) VerifyToken(ctx context.Context, token string) (*string, error) { 31 | response, err := r.client.VerifyToken(ctx, &pb.VerifyTokenRequest{Token: token}) 32 | if err != nil { 33 | return nil, err 34 | } 35 | return &response.Id, err 36 | } 37 | 38 | func (r *RepositoryImpl) RevokeToken(ctx context.Context, token string) (*string, error) { 39 | response, err := r.client.RevokeToken(ctx, &pb.RevokeTokenRequest{Token: token}) 40 | if err != nil { 41 | return nil, err 42 | } 43 | return &response.Token, err 44 | } 45 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | tsServices=("profile" "category" "catalog" "cart" "promo" "delivery" "order") 4 | goServices=("authentication" "search") 5 | authInternalServices=("account" "confirmation" "token") 6 | gatewayServices=("authentication" "category" "catalog" "cart" "order" "delivery" "profile" "promo" "search") 7 | 8 | function cleanup() { 9 | folderPath="$1/generated" 10 | echo "$folderPath" 11 | if [ -d "$folderPath" ]; then 12 | rm -r "$folderPath" 13 | fi 14 | mkdir -p "$folderPath" 15 | } 16 | 17 | function generateTS() { 18 | if [ -d "$1" ]; then 19 | cd "$1" || return 20 | npm run protoc 21 | cd .. 22 | fi 23 | } 24 | 25 | function generateGo() { 26 | path="$1/generated" 27 | protoc --go_out="$path" --go-grpc_out="$path" --proto_path=proto "$2.proto" 28 | } 29 | 30 | # generate code for TS services 31 | for service in "${tsServices[@]}"; do 32 | generateTS "$service" "$service" 33 | echo "generated proto for TS service: $service" 34 | done 35 | 36 | # generate code for Go services 37 | for service in "${goServices[@]}"; do 38 | cleanup "$service" 39 | if [ "$service" == "authentication" ]; then 40 | # generate code for authentication 41 | for innerService in "${authInternalServices[@]}"; do 42 | generateGo "$service" "$innerService" 43 | echo "generated proto for authentication: $innerService" 44 | done 45 | fi 46 | generateGo "$service" "$service" 47 | echo "generated proto for Go service: $service" 48 | done 49 | 50 | # generate code for gateway 51 | cleanup gateway 52 | for service in "${gatewayServices[@]}"; do 53 | generateGo gateway "$service" 54 | echo "generated proto for gateway: $service" 55 | done 56 | 57 | sleep .5 58 | -------------------------------------------------------------------------------- /cart/.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=cart 2 | REDIS_HOSTNAME=0.0.0.0 3 | REDIS_PORT=6379 4 | REDIS_NAME=cart 5 | SERVER_HOSTNAME=0.0.0.0 6 | SERVER_PORT=8003 -------------------------------------------------------------------------------- /cart/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /build/ 3 | /.idea/ 4 | /src/generated/ -------------------------------------------------------------------------------- /cart/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm install 5 | COPY tsconfig*.json . 6 | COPY .env . 7 | COPY ./src ./src 8 | RUN apk update && apk add protoc 9 | CMD ["rm", "-r", "src/generated"] 10 | CMD ["mkdir", "-p", "src/generated"] 11 | CMD ["protoc", "--plugin=./node_modules/.bin/protoc-gen-ts_proto", "--ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true", "--ts_proto_out=./src/generated", "-I=proto proto/*.proto"] 12 | RUN npm run build 13 | RUN npm prune --production 14 | 15 | FROM node:alpine 16 | COPY --from=0 app/.env . 17 | COPY --from=0 app/build . 18 | COPY --from=0 app/node_modules /node_modules 19 | CMD ["node", "index.js"] -------------------------------------------------------------------------------- /cart/README.md: -------------------------------------------------------------------------------- 1 | # Cart microservice 2 | 3 | ___ 4 | 5 | ## Technologies: 6 | 7 | - Node.js 8 | - TypeScript 9 | - Inversify 10 | - gRPC 11 | - TS-Proto 12 | - Redis 13 | 14 | ## Setup: 15 | 16 | ``` 17 | docker-compose up --build -d 18 | ``` -------------------------------------------------------------------------------- /cart/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | cart: 4 | build: . 5 | depends_on: 6 | - cart_redis 7 | environment: 8 | - REDIS_HOSTNAME=redis 9 | volumes: 10 | - ./:/data 11 | restart: on-failure 12 | ports: 13 | - '8003:8003' 14 | cart_redis: 15 | image: redis:latest 16 | restart: on-failure 17 | ports: 18 | - '6379:6379' -------------------------------------------------------------------------------- /cart/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cart", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "protoc": "npx protoc --plugin=protoc-gen-ts_proto=.\\node_modules\\.bin\\protoc-gen-ts_proto.cmd --ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true --ts_proto_out=./src/generated -I=../proto cart.proto", 7 | "protoc-debug": "npx rimraf src/generated && npx mkdirp src/generated && npm run protoc", 8 | "build": "tsc -p .", 9 | "dev": "ts-node src/index.ts", 10 | "test": "jest" 11 | }, 12 | "dependencies": { 13 | "@grpc/grpc-js": "^1.7.3", 14 | "dotenv": "^16.0.3", 15 | "fp-ts": "^2.13.1", 16 | "inversify": "^6.0.1", 17 | "redis": "^4.4.0", 18 | "reflect-metadata": "^0.1.13" 19 | }, 20 | "devDependencies": { 21 | "@types/jest": "^29.2.2", 22 | "@types/node": "^18.11.9", 23 | "grpc-tools": "^1.11.3", 24 | "ts-jest": "^29.0.3", 25 | "ts-node": "^10.9.1", 26 | "ts-proto": "^1.132.1", 27 | "typescript": "^4.8.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /cart/src/application/index.ts: -------------------------------------------------------------------------------- 1 | export const createApplication = (initialize: () => Promise, execute: () => Promise) => { 2 | Promise.all([initialize(), execute()]) 3 | .then(() => console.log("Successfully launched application.")) 4 | .catch(console.error); 5 | }; -------------------------------------------------------------------------------- /cart/src/cart/CartError.ts: -------------------------------------------------------------------------------- 1 | export namespace CartError { 2 | export const NotFound = new Error("Cart not found"); 3 | } -------------------------------------------------------------------------------- /cart/src/cart/ClearCart.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Types} from "../di/types"; 4 | import {CartRepository} from "./CartRepository"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {pipe} from "fp-ts/function"; 7 | import {taskEither as TE} from "fp-ts"; 8 | import {CartError} from "./CartError"; 9 | 10 | @injectable() 11 | export class ClearCart extends UseCase { 12 | constructor(@inject(Types.cart.repository) private readonly cartRepository: CartRepository) { 13 | super(); 14 | } 15 | 16 | execute = (arg: string): TaskEither => pipe( 17 | this.cartRepository.removeItemsByCartId(arg), 18 | TE.chain(TE.fromNullable(CartError.NotFound)) 19 | ); 20 | } -------------------------------------------------------------------------------- /cart/src/cart/DecreaseItemQuantity.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {CartRepository} from "./CartRepository"; 4 | import {TaskEither} from "fp-ts/TaskEither"; 5 | import {Types} from "../di/types"; 6 | import {Item} from "./Item"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CartError} from "./CartError"; 10 | 11 | @injectable() 12 | export class DecreaseItemQuantity extends UseCase<[string, string], Item | null> { 13 | constructor(@inject(Types.cart.repository) private readonly cartRepository: CartRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: [string, string]): TaskEither => pipe( 18 | TE.Do, 19 | TE.map(() => arg), 20 | TE.bind("cartId", ([cartId, _]) => TE.of(cartId)), 21 | TE.bind("itemId", ([_, itemId]) => TE.of(itemId)), 22 | TE.chain(({cartId, itemId}) => pipe( 23 | this.cartRepository.getItemById(cartId, itemId), 24 | TE.chain(TE.fromNullable(CartError.NotFound)), 25 | TE.chain(item => item.quantity > 1 ? this.cartRepository.updateItem(cartId, ({ 26 | id: item.id, 27 | quantity: item.quantity - 1, 28 | addedAt: item.addedAt 29 | })) : pipe( 30 | this.cartRepository.removeItemById(cartId, itemId), 31 | TE.chain(() => this.cartRepository.getItemById(cartId, itemId)) 32 | ) 33 | ) 34 | ) 35 | ) 36 | ); 37 | } -------------------------------------------------------------------------------- /cart/src/cart/GetCart.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Item} from "./Item"; 4 | import {CartRepository} from "./CartRepository"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {Types} from "../di/types"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CartError} from "./CartError"; 10 | 11 | @injectable() 12 | export class GetCart extends UseCase { 13 | constructor(@inject(Types.cart.repository) private readonly cartRepository: CartRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: string): TaskEither => pipe( 18 | this.cartRepository.getItemsByCartId(arg), 19 | TE.chain(TE.fromNullable(CartError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /cart/src/cart/IncreaseItemQuantity.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {CartRepository} from "./CartRepository"; 4 | import {TaskEither} from "fp-ts/TaskEither"; 5 | import {Types} from "../di/types"; 6 | import {Item} from "./Item"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CartError} from "./CartError"; 10 | 11 | @injectable() 12 | export class IncreaseItemQuantity extends UseCase<[string, string], Item> { 13 | constructor(@inject(Types.cart.repository) private readonly cartRepository: CartRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: [string, string]): TaskEither => pipe( 18 | TE.Do, 19 | TE.map(() => arg), 20 | TE.bind("cartId", ([cartId, _]) => TE.of(cartId)), 21 | TE.bind("itemId", ([_, itemId]) => TE.of(itemId)), 22 | TE.chain(({cartId, itemId}) => pipe( 23 | this.cartRepository.getItemById(cartId, itemId), 24 | TE.chain(TE.fromNullable(CartError.NotFound)), 25 | TE.chain(item => item ? pipe( 26 | this.cartRepository.updateItem(cartId, ({ 27 | id: item.id, 28 | quantity: item.quantity + 1, 29 | addedAt: item.addedAt 30 | })) 31 | ) : pipe( 32 | this.cartRepository.createItem(cartId, itemId), 33 | TE.chain(() => this.cartRepository.getItemById(cartId, itemId)) 34 | ) 35 | ), 36 | TE.chain(TE.fromNullable(CartError.NotFound)) 37 | ) 38 | ) 39 | ); 40 | } -------------------------------------------------------------------------------- /cart/src/cart/Item.ts: -------------------------------------------------------------------------------- 1 | export type Item = { 2 | id: string; 3 | quantity: number; 4 | addedAt: number; 5 | }; -------------------------------------------------------------------------------- /cart/src/cart/ItemMapper.ts: -------------------------------------------------------------------------------- 1 | import {CartItem as ItemMessage} from "../generated/cart"; 2 | import {Item} from "./Item"; 3 | 4 | export namespace ItemMapper { 5 | export const entityToMessage = (entity: Item): ItemMessage => ({ 6 | id: entity.id, 7 | quantity: entity.quantity, 8 | addedAt: entity.addedAt 9 | }); 10 | export const messageToEntity = (message: ItemMessage): Item => ({ 11 | id: message.id, 12 | quantity: message.quantity, 13 | addedAt: message.addedAt 14 | }); 15 | } -------------------------------------------------------------------------------- /cart/src/config/Config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from "inversify"; 2 | 3 | @injectable() 4 | export class Config { 5 | readonly REDIS_URL = `redis://${process.env.REDIS_HOSTNAME}:${process.env.REDIS_PORT}`; 6 | readonly REDIS_NAME = process.env.REDIS_NAME; 7 | readonly SERVER_URL = `${process.env.SERVER_HOSTNAME}:${process.env.SERVER_PORT}`; 8 | } -------------------------------------------------------------------------------- /cart/src/di/types.ts: -------------------------------------------------------------------------------- 1 | export namespace Types { 2 | export const app = { 3 | config: Symbol.for("config"), 4 | store: Symbol.for("store"), 5 | server: Symbol.for("server") 6 | }; 7 | export const cart = { 8 | repository: Symbol.for("cartRepository"), 9 | service: Symbol.for("cartService"), 10 | getCart: Symbol.for("getCart"), 11 | clearCart: Symbol.for("clearCart"), 12 | increaseItemQuantity: Symbol.for("increaseItemQuantity"), 13 | decreaseItemQuantity: Symbol.for("decreaseItemQuantity") 14 | }; 15 | } -------------------------------------------------------------------------------- /cart/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | import {Types} from "./di/types"; 3 | import {CartServiceServer, CartServiceService} from "./generated/cart"; 4 | import {Store} from "./store/Store"; 5 | import {Module} from "./di/module"; 6 | import {Server} from "./server/Server"; 7 | import {createApplication} from "./application"; 8 | 9 | 10 | const initialize = async () => { 11 | dotenv.config(); 12 | Module.initModules(); 13 | }; 14 | 15 | const execute = async () => { 16 | await Module.container.get(Types.app.store).open(); 17 | await Module.container.get(Types.app.server).launch(server => { 18 | server.addService(CartServiceService, Module.container.get(Types.cart.service)); 19 | }); 20 | }; 21 | 22 | createApplication(initialize, execute); -------------------------------------------------------------------------------- /cart/src/interactor/UseCase.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | 3 | export abstract class UseCase { 4 | abstract execute(arg: T): TaskEither 5 | } -------------------------------------------------------------------------------- /cart/src/response/ResponseError.ts: -------------------------------------------------------------------------------- 1 | export namespace ResponseError { 2 | export const map = new Error("Unable to map value"); 3 | } -------------------------------------------------------------------------------- /cart/src/response/index.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | import {either as E} from "fp-ts"; 3 | import {pipe} from "fp-ts/function"; 4 | import {Either} from "fp-ts/Either"; 5 | import {ResponseError} from "./ResponseError"; 6 | 7 | export const response = (input: TaskEither, callback: (e: Error | null, r: R | null) => void, map: (t: T) => R): void => { 8 | input().then((either: Either) => { 9 | pipe(either, 10 | E.chain((value: T) => 11 | E.tryCatch(() => map(value), e => e instanceof Error ? e : ResponseError.map)), 12 | E.fold((e: Error) => { 13 | console.error(e); 14 | callback(e, null); 15 | }, (value: R) => { 16 | callback(null, value); 17 | } 18 | ) 19 | ); 20 | }).catch((e: Error) => { 21 | console.error(e); 22 | callback(e, null); 23 | }); 24 | }; -------------------------------------------------------------------------------- /cart/src/server/Server.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {Types} from "../di/types"; 3 | import {Config} from "../config/Config"; 4 | import {Server as GrpcServer, ServerCredentials} from "@grpc/grpc-js"; 5 | 6 | @injectable() 7 | export class Server { 8 | constructor(@inject(Types.app.config) private readonly config: Config) { 9 | } 10 | 11 | async launch(bind: (server: GrpcServer) => void) { 12 | const server: GrpcServer = new GrpcServer(); 13 | bind(server); 14 | server.bindAsync(this.config.SERVER_URL, ServerCredentials.createInsecure(), (error, port) => { 15 | if (error) console.error(error); 16 | server.start(); 17 | console.log(`Server running on port: ${port}`); 18 | }); 19 | }; 20 | } -------------------------------------------------------------------------------- /cart/src/store/Store.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {createClient, RedisClientType} from "redis"; 3 | import {Config} from "../config/Config"; 4 | import {Types} from "../di/types"; 5 | 6 | @injectable() 7 | export class Store { 8 | client: RedisClientType | null = null; 9 | 10 | constructor(@inject(Types.app.config) private readonly config: Config) { 11 | } 12 | 13 | open = async () => new Promise((resolve, reject) => { 14 | try { 15 | resolve(createClient({ 16 | name: this.config.REDIS_NAME, 17 | url: this.config.REDIS_URL 18 | })); 19 | } catch (e) { 20 | reject(e); 21 | } 22 | }).then(client => { 23 | client.connect().then(() => { 24 | this.client = client; 25 | console.log(`Connected to store: ${this.config.REDIS_URL}`); 26 | }).catch(console.error); 27 | }).catch(console.error); 28 | 29 | close = () => this.client?.disconnect().then(() => { 30 | console.log(`Disconnected from store: ${this.config.REDIS_URL}`); 31 | this.client = null; 32 | }).catch(console.error); 33 | } -------------------------------------------------------------------------------- /cart/src/store/StoreError.ts: -------------------------------------------------------------------------------- 1 | export namespace StoreError { 2 | export const client = new Error("Unavailable client"); 3 | } -------------------------------------------------------------------------------- /catalog/.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=catalog 2 | MONGO_HOSTNAME=0.0.0.0 3 | MONGO_PORT=27017 4 | DATABASE_NAME=catalog 5 | COLLECTION_ITEMS=items 6 | SERVER_HOSTNAME=0.0.0.0 7 | SERVER_PORT=8002 8 | AMQP_HOSTNAME=0.0.0.0 9 | AMQP_PORT=5672 10 | AMQP_CHANNEL_CATALOG=catalog -------------------------------------------------------------------------------- /catalog/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /build/ 3 | /.idea/ 4 | /src/generated/ -------------------------------------------------------------------------------- /catalog/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm install 5 | COPY tsconfig*.json . 6 | COPY .env . 7 | COPY ./src ./src 8 | RUN apk update && apk add protoc 9 | CMD ["rm", "-r", "src/generated"] 10 | CMD ["mkdir", "-p", "src/generated"] 11 | CMD ["protoc", "--plugin=./node_modules/.bin/protoc-gen-ts_proto", "--ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true", "--ts_proto_out=./src/generated", "-I=proto proto/*.proto"] 12 | RUN npm run build 13 | RUN npm prune --production 14 | 15 | FROM node:alpine 16 | ENV REDIS_HOSTNAME=redis 17 | COPY --from=0 app/.env . 18 | COPY --from=0 app/build . 19 | COPY --from=0 app/node_modules /node_modules 20 | CMD ["node", "index.js"] -------------------------------------------------------------------------------- /catalog/README.md: -------------------------------------------------------------------------------- 1 | # Catalog microservice 2 | 3 | ___ 4 | 5 | ## Technologies: 6 | 7 | - Node.js 8 | - TypeScript 9 | - Inversify 10 | - gRPC 11 | - TS-Proto 12 | - MongoDB 13 | 14 | ![image](./media/ecommerce-backend-catalog.png) 15 | 16 | ## Setup: 17 | 18 | ``` 19 | docker-compose up --build -d 20 | ``` -------------------------------------------------------------------------------- /catalog/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | catalog: 4 | build: . 5 | depends_on: 6 | - catalog_mongo 7 | - catalog_rabbitmq 8 | environment: 9 | - MONGO_HOSTNAME=mongodb 10 | - RABBITMQ_HOSTNAME=rabbitmq 11 | volumes: 12 | - ./:/data 13 | restart: on-failure 14 | ports: 15 | - '8002:8002' 16 | catalog_mongo: 17 | image: mongo:latest 18 | restart: on-failure 19 | ports: 20 | - '27017:27017' 21 | catalog_rabbitmq: 22 | image: rabbitmq:latest 23 | restart: on-failure 24 | ports: 25 | - '5672:5672' -------------------------------------------------------------------------------- /catalog/media/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numq/ecommerce-backend/62cda50e1cc9a70f556687f2091dfcaebf1ce8e7/catalog/media/.gitkeep -------------------------------------------------------------------------------- /catalog/media/ecommerce-backend-catalog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numq/ecommerce-backend/62cda50e1cc9a70f556687f2091dfcaebf1ce8e7/catalog/media/ecommerce-backend-catalog.png -------------------------------------------------------------------------------- /catalog/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "catalog", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "protoc": "npx protoc --plugin=protoc-gen-ts_proto=.\\node_modules\\.bin\\protoc-gen-ts_proto.cmd --ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true --ts_proto_out=./src/generated -I=../proto catalog.proto", 7 | "protoc-debug": "npx rimraf src/generated && npx mkdirp src/generated && npm run protoc", 8 | "build": "tsc -p .", 9 | "dev": "ts-node src/index.ts", 10 | "test": "jest" 11 | }, 12 | "dependencies": { 13 | "@grpc/grpc-js": "^1.7.3", 14 | "@types/amqplib": "^0.10.1", 15 | "amqplib": "^0.10.3", 16 | "dotenv": "^16.0.3", 17 | "fp-ts": "^2.13.1", 18 | "inversify": "^6.0.1", 19 | "mongodb": "^4.11.0", 20 | "reflect-metadata": "^0.1.13" 21 | }, 22 | "devDependencies": { 23 | "@types/jest": "^29.2.2", 24 | "@types/node": "^18.11.9", 25 | "grpc-tools": "^1.11.3", 26 | "ts-jest": "^29.0.3", 27 | "ts-node": "^10.9.1", 28 | "ts-proto": "^1.132.1", 29 | "typescript": "^4.8.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /catalog/src/amqp/MessageQueue.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import client, {Channel, Connection} from "amqplib"; 3 | import {Types} from "../di/types"; 4 | import {Config} from "../config/Config"; 5 | 6 | @injectable() 7 | export class MessageQueue { 8 | 9 | constructor(@inject(Types.app.config) private readonly config: Config) { 10 | } 11 | 12 | private connection: Connection | null = null; 13 | private channels: Map = new Map(); 14 | 15 | connect = () => client.connect(this.config.AMQP_URL).then(connection => { 16 | console.log(`Connected to message queue: ${this.config.AMQP_URL}`); 17 | this.connection = connection 18 | }).catch(console.error); 19 | 20 | disconnect = () => this.connection?.close().then(() => { 21 | console.log(`Disconnected from message queue: ${this.config.AMQP_URL}`); 22 | this.connection = null; 23 | }).catch(console.error); 24 | 25 | openChannel = (name: string) => this.connection?.createChannel().then(channel => { 26 | this.channels.set(name, channel); 27 | }); 28 | 29 | closeChannel = (name: string) => this.channels.get(name)?.close().then(() => { 30 | this.channels.delete(name); 31 | }); 32 | 33 | useChannel = (name: string): Channel | null => { 34 | const channel = this.channels.get(name); 35 | return channel ? channel : null; 36 | }; 37 | 38 | } -------------------------------------------------------------------------------- /catalog/src/app/index.ts: -------------------------------------------------------------------------------- 1 | export const createApplication = (initialize: () => Promise, execute: () => Promise) => { 2 | Promise.all([initialize(), execute()]) 3 | .then(() => console.log("Successfully launched application.")) 4 | .catch(console.error); 5 | }; -------------------------------------------------------------------------------- /catalog/src/catalog/AddCatalogItem.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {CatalogRepository} from "./CatalogRepository"; 3 | import {UseCase} from "../interactor/UseCase"; 4 | import {CatalogItem} from "./CatalogItem"; 5 | import {Types} from "../di/types"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CatalogError} from "./CatalogError"; 10 | 11 | @injectable() 12 | export class AddCatalogItem extends UseCase { 13 | constructor(@inject(Types.catalog.repository) private readonly repository: CatalogRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: CatalogItem): TaskEither => pipe( 18 | this.repository.addItem(arg), 19 | TE.chain(TE.fromNullable(CatalogError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /catalog/src/catalog/CatalogError.ts: -------------------------------------------------------------------------------- 1 | export namespace CatalogError { 2 | export const NotFound = new Error("Catalog not found"); 3 | } -------------------------------------------------------------------------------- /catalog/src/catalog/CatalogItem.ts: -------------------------------------------------------------------------------- 1 | export type CatalogItem = { 2 | id: string; 3 | sku: string; 4 | name: string; 5 | description: string; 6 | imageBytes: Uint8Array; 7 | price: number; 8 | discount: number; 9 | weight: number; 10 | quantity: number; 11 | tags: string[]; 12 | createdAt: number; 13 | updatedAt: number; 14 | }; -------------------------------------------------------------------------------- /catalog/src/catalog/CatalogItemMapper.ts: -------------------------------------------------------------------------------- 1 | import {CatalogItem} from "./CatalogItem"; 2 | import {CatalogItem as CatalogItemMessage} from "../generated/catalog"; 3 | import {Buffer} from "buffer"; 4 | 5 | export namespace CatalogItemMapper { 6 | export const entityToMessage = (entity: CatalogItem): CatalogItemMessage => ({ 7 | id: entity.id, 8 | sku: entity.sku, 9 | name: entity.name, 10 | description: entity.description, 11 | imageBytes: Buffer.from(entity.imageBytes), 12 | price: entity.price, 13 | discount: entity.discount, 14 | weight: entity.weight, 15 | quantity: entity.quantity, 16 | tags: entity.tags, 17 | createdAt: entity.createdAt, 18 | updatedAt: entity.updatedAt 19 | }); 20 | export const messageToEntity = (message: CatalogItemMessage): CatalogItem => ({ 21 | id: message.id, 22 | sku: message.sku, 23 | name: message.name, 24 | description: message.description, 25 | imageBytes: new Uint8Array(message.imageBytes), 26 | price: message.price, 27 | discount: message.discount, 28 | weight: message.weight, 29 | quantity: message.quantity, 30 | tags: message.tags, 31 | createdAt: message.createdAt, 32 | updatedAt: message.updatedAt 33 | }); 34 | } -------------------------------------------------------------------------------- /catalog/src/catalog/GetCatalogItemById.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {CatalogItem} from "./CatalogItem"; 4 | import {Types} from "../di/types"; 5 | import {CatalogRepository} from "./CatalogRepository"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CatalogError} from "./CatalogError"; 10 | 11 | @injectable() 12 | export class GetCatalogItemById extends UseCase { 13 | constructor(@inject(Types.catalog.repository) private readonly repository: CatalogRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: string): TaskEither => pipe( 18 | this.repository.getItemById(arg), 19 | TE.chain(TE.fromNullable(CatalogError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /catalog/src/catalog/GetCatalogItemsByTags.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {CatalogItem} from "./CatalogItem"; 4 | import {Types} from "../di/types"; 5 | import {CatalogRepository} from "./CatalogRepository"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CatalogError} from "./CatalogError"; 10 | import {SortType} from "./SortType"; 11 | 12 | @injectable() 13 | export class GetCatalogItemsByTags extends UseCase<[string[], SortType, number, number], CatalogItem[]> { 14 | constructor(@inject(Types.catalog.repository) private readonly repository: CatalogRepository) { 15 | super(); 16 | } 17 | 18 | execute = (arg: [string[], SortType, number, number]): TaskEither => pipe( 19 | this.repository.getItemsByTags(...arg), 20 | TE.chain(TE.fromNullable(CatalogError.NotFound)) 21 | ); 22 | } -------------------------------------------------------------------------------- /catalog/src/catalog/RemoveCatalogItem.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Types} from "../di/types"; 4 | import {CatalogRepository} from "./CatalogRepository"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {pipe} from "fp-ts/function"; 7 | import {taskEither as TE} from "fp-ts"; 8 | import {CatalogError} from "./CatalogError"; 9 | 10 | @injectable() 11 | export class RemoveCatalogItem extends UseCase { 12 | constructor(@inject(Types.catalog.repository) private readonly repository: CatalogRepository) { 13 | super(); 14 | } 15 | 16 | execute = (arg: string): TaskEither => pipe( 17 | this.repository.removeItem(arg), 18 | TE.chain(TE.fromNullable(CatalogError.NotFound)) 19 | ); 20 | } -------------------------------------------------------------------------------- /catalog/src/catalog/SortType.ts: -------------------------------------------------------------------------------- 1 | export enum SortType { 2 | CHEAPEST_FIRST, EXPENSIVE_FIRST, DISCOUNTED_FIRST, NEWEST_FIRST, ALPHABETICALLY 3 | } -------------------------------------------------------------------------------- /catalog/src/catalog/UpdateCatalogItem.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {CatalogItem} from "./CatalogItem"; 4 | import {Types} from "../di/types"; 5 | import {CatalogRepository} from "./CatalogRepository"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CatalogError} from "./CatalogError"; 10 | 11 | @injectable() 12 | export class UpdateCatalogItem extends UseCase { 13 | constructor(@inject(Types.catalog.repository) private readonly repository: CatalogRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: CatalogItem): TaskEither => pipe( 18 | this.repository.updateItem(arg), 19 | TE.chain(TE.fromNullable(CatalogError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /catalog/src/config/Config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from "inversify"; 2 | 3 | @injectable() 4 | export class Config { 5 | readonly MONGO_URL = `mongodb://${process.env.MONGO_HOSTNAME}:${process.env.MONGO_PORT}`; 6 | readonly SERVER_URL = `${process.env.SERVER_HOSTNAME}:${process.env.SERVER_PORT}`; 7 | readonly DATABASE_NAME = process.env.DATABASE_NAME; 8 | readonly COLLECTION_ITEMS = process.env.COLLECTION_ITEMS; 9 | readonly AMQP_URL = `amqp://${process.env.AMQP_HOSTNAME}:${process.env.AMQP_PORT}`; 10 | readonly AMQP_CHANNEL_CATALOG = process.env.AMQP_CHANNEL_CATALOG; 11 | } -------------------------------------------------------------------------------- /catalog/src/database/Database.ts: -------------------------------------------------------------------------------- 1 | import {Document, MongoClient} from "mongodb"; 2 | import {inject, injectable} from "inversify"; 3 | import {Types} from "../di/types"; 4 | import {Config} from "../config/Config"; 5 | 6 | @injectable() 7 | export class Database { 8 | client: MongoClient | null = null; 9 | 10 | constructor(@inject(Types.app.config) private readonly config: Config) { 11 | } 12 | 13 | open = async () => new MongoClient(this.config.MONGO_URL).connect().then(client => { 14 | this.client = client; 15 | console.log(`Connected to database: ${this.config.MONGO_URL}`); 16 | }).catch(console.error); 17 | 18 | close = () => this.client?.close().then(() => { 19 | console.log(`Disconnected from database: ${this.config.MONGO_URL}`); 20 | this.client = null; 21 | }).catch(console.error); 22 | 23 | collection = (name: string) => this.client?.db(this.config.DATABASE_NAME).collection(name); 24 | } -------------------------------------------------------------------------------- /catalog/src/di/types.ts: -------------------------------------------------------------------------------- 1 | export namespace Types { 2 | export const app = { 3 | config: Symbol.for("config"), 4 | database: Symbol.for("database"), 5 | server: Symbol.for("server"), 6 | messageQueue: Symbol.for("messageQueue") 7 | }; 8 | export const catalog = { 9 | channel: Symbol.for("catalogChannel"), 10 | collection: Symbol.for("catalogCollection"), 11 | repository: Symbol.for("catalogRepository"), 12 | service: Symbol.for("catalogService"), 13 | addCatalogItem: Symbol.for("addCatalogItem"), 14 | getCatalogItemById: Symbol.for("getCatalogItemById"), 15 | getCatalogItemsByTags: Symbol.for("getCatalogItemsByTags"), 16 | updateCatalogItem: Symbol.for("updateCatalogItem"), 17 | removeCatalogItem: Symbol.for("removeCatalogItem") 18 | }; 19 | } -------------------------------------------------------------------------------- /catalog/src/index.ts: -------------------------------------------------------------------------------- 1 | import {Module} from "./di/module"; 2 | import {Types} from "./di/types"; 3 | import {Server} from "./server/Server"; 4 | import * as dotenv from "dotenv"; 5 | import {CatalogServiceServer, CatalogServiceService} from "./generated/catalog"; 6 | import {Database} from "./database/Database"; 7 | import {createApplication} from "./app"; 8 | import {MessageQueue} from "./amqp/MessageQueue"; 9 | import {Config} from "./config/Config"; 10 | 11 | const initialize = async () => { 12 | dotenv.config(); 13 | Module.initModules(); 14 | }; 15 | 16 | const execute = async () => { 17 | const messageQueue = Module.container.get(Types.app.messageQueue); 18 | await messageQueue.connect(); 19 | await messageQueue.openChannel(Module.container.get(Types.app.config).AMQP_CHANNEL_CATALOG!!)!!; 20 | await Module.container.get(Types.app.database).open(); 21 | await Module.container.get(Types.app.server).launch(server => { 22 | server.addService(CatalogServiceService, Module.container.get(Types.catalog.service)); 23 | }); 24 | }; 25 | 26 | createApplication(initialize, execute); -------------------------------------------------------------------------------- /catalog/src/interactor/UseCase.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | 3 | export abstract class UseCase { 4 | abstract execute(arg: T): TaskEither 5 | } -------------------------------------------------------------------------------- /catalog/src/response/ResponseError.ts: -------------------------------------------------------------------------------- 1 | export namespace ResponseError { 2 | export const map = new Error("Unable to map value"); 3 | } -------------------------------------------------------------------------------- /catalog/src/response/index.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | import {flow} from "fp-ts/function"; 3 | import {either as E} from "fp-ts"; 4 | import {ResponseError} from "./ResponseError"; 5 | 6 | export const response = (input: TaskEither, callback: (e: Error | null, r: R | null) => void, map: (t: T) => R): void => { 7 | input().then(flow(E.chain((t: T) => E.tryCatch(() => map(t), () => ResponseError.map)), E.fold((e: Error) => callback(e, null), (r: R) => callback(null, r)))).catch(e => callback(e, null)); 8 | } -------------------------------------------------------------------------------- /catalog/src/server/Server.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {Types} from "../di/types"; 3 | import {Config} from "../config/Config"; 4 | import {Server as GrpcServer, ServerCredentials} from "@grpc/grpc-js"; 5 | 6 | @injectable() 7 | export class Server { 8 | constructor(@inject(Types.app.config) private readonly config: Config) { 9 | } 10 | 11 | async launch(bind: (server: GrpcServer) => void) { 12 | const server: GrpcServer = new GrpcServer(); 13 | bind(server); 14 | server.bindAsync(this.config.SERVER_URL, ServerCredentials.createInsecure(), (error, port) => { 15 | if (error) console.error(error); 16 | server.start(); 17 | console.log(`Server running on port: ${port}`); 18 | }); 19 | }; 20 | } -------------------------------------------------------------------------------- /category/.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=category 2 | MONGO_HOSTNAME=0.0.0.0 3 | MONGO_PORT=27017 4 | DATABASE_NAME=category 5 | COLLECTION_ITEMS=items 6 | SERVER_HOSTNAME=0.0.0.0 7 | SERVER_PORT=8001 -------------------------------------------------------------------------------- /category/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /build/ 3 | /.idea/ 4 | /src/generated/ -------------------------------------------------------------------------------- /category/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm install 5 | COPY tsconfig*.json . 6 | COPY .env . 7 | COPY ./src ./src 8 | RUN apk update && apk add protoc 9 | CMD ["rm", "-r", "src/generated"] 10 | CMD ["mkdir", "-p", "src/generated"] 11 | CMD ["protoc", "--plugin=./node_modules/.bin/protoc-gen-ts_proto", "--ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true", "--ts_proto_out=./src/generated", "-I=proto proto/*.proto"] 12 | RUN npm run build 13 | RUN npm prune --production 14 | 15 | FROM node:alpine 16 | ENV REDIS_HOSTNAME=redis 17 | COPY --from=0 app/.env . 18 | COPY --from=0 app/build . 19 | COPY --from=0 app/node_modules /node_modules 20 | CMD ["node", "index.js"] -------------------------------------------------------------------------------- /category/README.md: -------------------------------------------------------------------------------- 1 | # Category microservice 2 | 3 | ___ 4 | 5 | ## Technologies: 6 | 7 | - Node.js 8 | - TypeScript 9 | - Inversify 10 | - gRPC 11 | - TS-Proto 12 | - MongoDB 13 | 14 | ![image](./media/ecommerce-backend-category.png) 15 | 16 | ## Setup: 17 | 18 | ``` 19 | docker-compose up --build -d 20 | ``` -------------------------------------------------------------------------------- /category/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | category: 4 | build: . 5 | depends_on: 6 | - category_mongo 7 | environment: 8 | - MONGO_HOSTNAME=mongodb 9 | volumes: 10 | - ./:/data 11 | restart: on-failure 12 | ports: 13 | - '8001:8001' 14 | category_mongo: 15 | image: mongo:latest 16 | restart: on-failure 17 | ports: 18 | - '27017:27017' -------------------------------------------------------------------------------- /category/media/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numq/ecommerce-backend/62cda50e1cc9a70f556687f2091dfcaebf1ce8e7/category/media/.gitkeep -------------------------------------------------------------------------------- /category/media/ecommerce-backend-category.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numq/ecommerce-backend/62cda50e1cc9a70f556687f2091dfcaebf1ce8e7/category/media/ecommerce-backend-category.png -------------------------------------------------------------------------------- /category/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "category", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "protoc": "npx protoc --plugin=protoc-gen-ts_proto=.\\node_modules\\.bin\\protoc-gen-ts_proto.cmd --ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true --ts_proto_out=./src/generated -I=../proto category.proto", 7 | "protoc-debug": "npx rimraf src/generated && npx mkdirp src/generated && npm run protoc", 8 | "build": "tsc -p .", 9 | "dev": "ts-node src/index.ts", 10 | "test": "jest" 11 | }, 12 | "dependencies": { 13 | "@grpc/grpc-js": "^1.7.3", 14 | "dotenv": "^16.0.3", 15 | "fp-ts": "^2.13.1", 16 | "inversify": "^6.0.1", 17 | "mongodb": "^4.11.0", 18 | "reflect-metadata": "^0.1.13" 19 | }, 20 | "devDependencies": { 21 | "@types/jest": "^29.2.2", 22 | "@types/node": "^18.11.9", 23 | "grpc-tools": "^1.11.3", 24 | "ts-jest": "^29.0.3", 25 | "ts-node": "^10.9.1", 26 | "ts-proto": "^1.132.1", 27 | "typescript": "^4.8.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /category/src/app/index.ts: -------------------------------------------------------------------------------- 1 | export const createApplication = (initialize: () => Promise, execute: () => Promise) => { 2 | Promise.all([initialize(), execute()]) 3 | .then(() => console.log("Successfully launched application.")) 4 | .catch(console.error); 5 | }; -------------------------------------------------------------------------------- /category/src/category/AddCategory.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {Category} from "./Category"; 3 | import {CategoryRepository} from "./CategoryRepository"; 4 | import {TaskEither} from "fp-ts/TaskEither"; 5 | import {UseCase} from "../interactor/UseCase"; 6 | import {Types} from "../di/types"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CategoryError} from "./CategoryError"; 10 | 11 | @injectable() 12 | export class AddCategory extends UseCase { 13 | constructor(@inject(Types.category.repository) private readonly repository: CategoryRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: Category): TaskEither => pipe( 18 | this.repository.addCategory(arg), 19 | TE.chain(TE.fromNullable(CategoryError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /category/src/category/Category.ts: -------------------------------------------------------------------------------- 1 | export type Category = { 2 | id: string; 3 | name: string; 4 | description: string; 5 | imageBytes: Uint8Array; 6 | tags: string[]; 7 | createdAt: number; 8 | updatedAt: number; 9 | }; -------------------------------------------------------------------------------- /category/src/category/CategoryError.ts: -------------------------------------------------------------------------------- 1 | export namespace CategoryError { 2 | export const NotFound = new Error("Category not found"); 3 | } -------------------------------------------------------------------------------- /category/src/category/CategoryMapper.ts: -------------------------------------------------------------------------------- 1 | import {Category} from "./Category"; 2 | import {Category as CategoryMessage} from "../generated/category"; 3 | 4 | export namespace CategoryMapper { 5 | export const entityToMessage = (entity: Category): CategoryMessage => ({ 6 | id: entity.id, 7 | name: entity.name, 8 | description: entity.description, 9 | imageBytes: Buffer.from(entity.imageBytes), 10 | tags: entity.tags, 11 | createdAt: entity.createdAt, 12 | updatedAt: entity.updatedAt 13 | }); 14 | export const messageToEntity = (message: CategoryMessage): Category => ({ 15 | id: message.id, 16 | name: message.name, 17 | description: message.description, 18 | imageBytes: new Uint8Array(message.imageBytes), 19 | tags: message.tags, 20 | createdAt: message.createdAt, 21 | updatedAt: message.updatedAt 22 | }); 23 | } -------------------------------------------------------------------------------- /category/src/category/GetCategories.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {CategoryRepository} from "./CategoryRepository"; 3 | import {Category} from "./Category"; 4 | import {TaskEither} from "fp-ts/TaskEither"; 5 | import {UseCase} from "../interactor/UseCase"; 6 | import {Types} from "../di/types"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CategoryError} from "./CategoryError"; 10 | 11 | @injectable() 12 | export class GetCategories extends UseCase<[number, number], Category[]> { 13 | constructor(@inject(Types.category.repository) private readonly repository: CategoryRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: [number, number]): TaskEither => pipe( 18 | this.repository.getCategories(...arg), 19 | TE.chain(TE.fromNullable(CategoryError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /category/src/category/GetCategoriesByTags.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {CategoryRepository} from "./CategoryRepository"; 3 | import {Category} from "./Category"; 4 | import {TaskEither} from "fp-ts/TaskEither"; 5 | import {UseCase} from "../interactor/UseCase"; 6 | import {Types} from "../di/types"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CategoryError} from "./CategoryError"; 10 | 11 | @injectable() 12 | export class GetCategoriesByTags extends UseCase<[string[], number, number], Category[]> { 13 | constructor(@inject(Types.category.repository) private readonly repository: CategoryRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: [string[], number, number]): TaskEither => pipe( 18 | this.repository.getCategoriesByTags(...arg), 19 | TE.chain(TE.fromNullable(CategoryError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /category/src/category/GetCategoryById.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {Category} from "./Category"; 3 | import {CategoryRepository} from "./CategoryRepository"; 4 | import {TaskEither} from "fp-ts/TaskEither"; 5 | import {UseCase} from "../interactor/UseCase"; 6 | import {Types} from "../di/types"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CategoryError} from "./CategoryError"; 10 | 11 | @injectable() 12 | export class GetCategoryById extends UseCase { 13 | constructor(@inject(Types.category.repository) private readonly repository: CategoryRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: string): TaskEither => pipe( 18 | this.repository.getCategoryById(arg), 19 | TE.chain(TE.fromNullable(CategoryError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /category/src/category/RemoveCategory.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {CategoryRepository} from "./CategoryRepository"; 3 | import {TaskEither} from "fp-ts/TaskEither"; 4 | import {UseCase} from "../interactor/UseCase"; 5 | import {Types} from "../di/types"; 6 | import {pipe} from "fp-ts/function"; 7 | import {taskEither as TE} from "fp-ts"; 8 | import {CategoryError} from "./CategoryError"; 9 | 10 | @injectable() 11 | export class RemoveCategory extends UseCase { 12 | constructor(@inject(Types.category.repository) private readonly repository: CategoryRepository) { 13 | super(); 14 | } 15 | 16 | execute = (arg: string): TaskEither => pipe( 17 | this.repository.removeCategory(arg), 18 | TE.chain(TE.fromNullable(CategoryError.NotFound)) 19 | ); 20 | } -------------------------------------------------------------------------------- /category/src/category/UpdateCategory.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {Category} from "./Category"; 3 | import {CategoryRepository} from "./CategoryRepository"; 4 | import {TaskEither} from "fp-ts/TaskEither"; 5 | import {UseCase} from "../interactor/UseCase"; 6 | import {Types} from "../di/types"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {CategoryError} from "./CategoryError"; 10 | 11 | @injectable() 12 | export class UpdateCategory extends UseCase { 13 | constructor(@inject(Types.category.repository) private readonly repository: CategoryRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: Category): TaskEither => pipe( 18 | this.repository.updateCategory(arg), 19 | TE.chain(TE.fromNullable(CategoryError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /category/src/config/Config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from "inversify"; 2 | 3 | @injectable() 4 | export class Config { 5 | readonly MONGO_URL = `mongodb://${process.env.MONGO_HOSTNAME}:${process.env.MONGO_PORT}`; 6 | readonly SERVER_URL = `${process.env.SERVER_HOSTNAME}:${process.env.SERVER_PORT}`; 7 | readonly DATABASE_NAME = process.env.DATABASE_NAME; 8 | readonly COLLECTION_ITEMS = process.env.COLLECTION_ITEMS; 9 | } -------------------------------------------------------------------------------- /category/src/database/Database.ts: -------------------------------------------------------------------------------- 1 | import {Document, MongoClient} from "mongodb"; 2 | import {inject, injectable} from "inversify"; 3 | import {Types} from "../di/types"; 4 | import {Config} from "../config/Config"; 5 | 6 | @injectable() 7 | export class Database { 8 | client: MongoClient | null = null; 9 | 10 | constructor(@inject(Types.app.config) private readonly config: Config) { 11 | } 12 | 13 | open = async () => new MongoClient(this.config.MONGO_URL).connect().then(client => { 14 | this.client = client; 15 | console.log(`Connected to database: ${this.config.MONGO_URL}`); 16 | }).catch(console.error); 17 | 18 | close = () => this.client?.close().then(() => { 19 | console.log(`Disconnected from database: ${this.config.MONGO_URL}`); 20 | this.client = null; 21 | }).catch(console.error); 22 | 23 | collection = (name: string) => this.client?.db(this.config.DATABASE_NAME).collection(name); 24 | } -------------------------------------------------------------------------------- /category/src/di/types.ts: -------------------------------------------------------------------------------- 1 | export namespace Types { 2 | export const app = { 3 | config: Symbol.for("appConfig"), 4 | database: Symbol.for("appDatabase"), 5 | server: Symbol.for("appServer") 6 | }; 7 | export const category = { 8 | collection: Symbol.for("categoryCollection"), 9 | repository: Symbol.for("categoryRepository"), 10 | service: Symbol.for("categoryService"), 11 | addCategory: Symbol.for("addCategory"), 12 | getCategoryById: Symbol.for("getCategoryById"), 13 | getCategories: Symbol.for("getCategories"), 14 | getCategoriesByTags: Symbol.for("getCategoriesByTags"), 15 | updateCategory: Symbol.for("updateCategory"), 16 | removeCategory: Symbol.for("removeCategory") 17 | }; 18 | } -------------------------------------------------------------------------------- /category/src/index.ts: -------------------------------------------------------------------------------- 1 | import {Module} from "./di/module"; 2 | import {Types} from "./di/types"; 3 | import {Server} from "./server/Server"; 4 | import * as dotenv from "dotenv"; 5 | import {CategoryServiceServer, CategoryServiceService} from "./generated/category"; 6 | import {Database} from "./database/Database"; 7 | import {createApplication} from "./app"; 8 | 9 | const initialize = async () => { 10 | dotenv.config(); 11 | Module.initModules(); 12 | }; 13 | 14 | const execute = async () => { 15 | await Module.container.get(Types.app.database).open(); 16 | await Module.container.get(Types.app.server).launch(server => { 17 | server.addService(CategoryServiceService, Module.container.get(Types.category.service)); 18 | }); 19 | }; 20 | 21 | createApplication(initialize, execute); -------------------------------------------------------------------------------- /category/src/interactor/UseCase.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | 3 | export abstract class UseCase { 4 | abstract execute(arg: T): TaskEither 5 | } -------------------------------------------------------------------------------- /category/src/response/ResponseError.ts: -------------------------------------------------------------------------------- 1 | export namespace ResponseError { 2 | export const map = new Error("Unable to map value"); 3 | } -------------------------------------------------------------------------------- /category/src/response/index.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | import {flow} from "fp-ts/function"; 3 | import {either as E} from "fp-ts"; 4 | import {ResponseError} from "./ResponseError"; 5 | 6 | export const response = (input: TaskEither, callback: (e: Error | null, r: R | null) => void, map: (t: T) => R): void => { 7 | input().then(flow(E.chain((t: T) => E.tryCatch(() => map(t), () => ResponseError.map)), E.fold((e: Error) => callback(e, null), (r: R) => callback(null, r)))).catch(e => callback(e, null)); 8 | } -------------------------------------------------------------------------------- /category/src/server/Server.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {Types} from "../di/types"; 3 | import {Config} from "../config/Config"; 4 | import {Server as GrpcServer, ServerCredentials} from "@grpc/grpc-js"; 5 | 6 | @injectable() 7 | export class Server { 8 | constructor(@inject(Types.app.config) private readonly config: Config) { 9 | } 10 | 11 | async launch(bind: (server: GrpcServer) => void) { 12 | const server: GrpcServer = new GrpcServer(); 13 | bind(server); 14 | server.bindAsync(this.config.SERVER_URL, ServerCredentials.createInsecure(), (error, port) => { 15 | if (error) console.error(error); 16 | server.start(); 17 | console.log(`Server running on port: ${port}`); 18 | }); 19 | }; 20 | } -------------------------------------------------------------------------------- /confirmation/.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /generated -------------------------------------------------------------------------------- /confirmation/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine 2 | WORKDIR /build 3 | COPY . . 4 | RUN apk update && apk add protoc 5 | CMD ["rm","-r","generated"] 6 | CMD ["mkdir","-p","generated"] 7 | CMD ["protoc", "--go_out=generated", "--go-grpc_out=generated", "--proto_path=proto", "proto/*.proto"] 8 | RUN go get -d -v ./... 9 | RUN go build -o target ./main 10 | 11 | FROM alpine:latest 12 | COPY --from=0 build/config/prod.env . 13 | COPY --from=0 build/target . 14 | CMD ["./target","-production"] -------------------------------------------------------------------------------- /confirmation/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | "log" 6 | ) 7 | 8 | type Config struct { 9 | ServiceName string `mapstructure:"SERVICE_NAME"` 10 | ServerAddress string `mapstructure:"SERVER_ADDRESS"` 11 | RedisHostname string `mapstructure:"REDIS_HOSTNAME"` 12 | RedisPort string `mapstructure:"REDIS_PORT"` 13 | ApiKey string `mapstructure:"API_KEY"` 14 | SecretKey string `mapstructure:"SECRET_KEY"` 15 | } 16 | 17 | func LoadConfig(name string) (config Config, err error) { 18 | viper.AddConfigPath(".") 19 | viper.AddConfigPath("./config") 20 | viper.SetConfigType("env") 21 | viper.SetConfigName(name) 22 | if err = viper.ReadInConfig(); err != nil { 23 | log.Fatal(err) 24 | } 25 | if err = viper.Unmarshal(&config); err != nil { 26 | log.Fatal(err) 27 | } 28 | return 29 | } 30 | -------------------------------------------------------------------------------- /confirmation/config/dev.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=confirmation 2 | SERVER_ADDRESS=0.0.0.0:8083 3 | REDIS_HOSTNAME=0.0.0.0 4 | REDIS_PORT=6379 5 | API_KEY=aW52b2x2ZWRzcHJpbmdjb25zb25hbnRzaGFsbG1lbWJlcmJ5bW9ua2V5ZXhlcmNpc2U= -------------------------------------------------------------------------------- /confirmation/config/prod.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=confirmation 2 | SERVER_ADDRESS=0.0.0.0:8083 3 | REDIS_HOSTNAME=redis 4 | REDIS_PORT=6379 5 | API_KEY=aW52b2x2ZWRzcHJpbmdjb25zb25hbnRzaGFsbG1lbWJlcmJ5bW9ua2V5ZXhlcmNpc2U= -------------------------------------------------------------------------------- /confirmation/confirmation/error.go: -------------------------------------------------------------------------------- 1 | package confirmation 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | var ( 8 | NotYetTime = fmt.Errorf("not yet time") 9 | WrongConfirmationCode = fmt.Errorf("wrong confirmation code") 10 | ) 11 | -------------------------------------------------------------------------------- /confirmation/confirmation/interactor.go: -------------------------------------------------------------------------------- 1 | package confirmation 2 | 3 | import "context" 4 | 5 | type UseCase interface { 6 | SendPhoneNumberConfirmation(ctx context.Context, phoneNumber string) (*int64, error) 7 | VerifyPhoneNumberConfirmation(ctx context.Context, phoneNumber string, confirmationCode string) (*string, error) 8 | } 9 | 10 | type UseCaseImpl struct { 11 | repository Repository 12 | } 13 | 14 | func NewUseCase(repository Repository) UseCase { 15 | return &UseCaseImpl{repository: repository} 16 | } 17 | 18 | func (u *UseCaseImpl) SendPhoneNumberConfirmation(ctx context.Context, phoneNumber string) (*int64, error) { 19 | return u.repository.SendPhoneNumberConfirmation(ctx, phoneNumber) 20 | } 21 | 22 | func (u *UseCaseImpl) VerifyPhoneNumberConfirmation(ctx context.Context, phoneNumber string, confirmationCode string) (*string, error) { 23 | return u.repository.VerifyPhoneNumberConfirmation(ctx, phoneNumber, confirmationCode) 24 | } 25 | -------------------------------------------------------------------------------- /confirmation/confirmation/repository.go: -------------------------------------------------------------------------------- 1 | package confirmation 2 | 3 | import ( 4 | "context" 5 | "github.com/go-redis/redis/v9" 6 | "time" 7 | ) 8 | 9 | type Repository interface { 10 | SendPhoneNumberConfirmation(ctx context.Context, phoneNumber string) (*int64, error) 11 | VerifyPhoneNumberConfirmation(ctx context.Context, phoneNumber string, confirmationCode string) (*string, error) 12 | } 13 | 14 | type RepositoryImpl struct { 15 | client *redis.Client 16 | } 17 | 18 | func NewRepository(client *redis.Client) Repository { 19 | return &RepositoryImpl{client: client} 20 | } 21 | 22 | func (r *RepositoryImpl) SendPhoneNumberConfirmation(ctx context.Context, phoneNumber string) (*int64, error) { 23 | if r.client.Exists(ctx, phoneNumber).Val() != 0 { 24 | return nil, NotYetTime 25 | } 26 | retryAt := time.Now().Add(time.Second * 90) 27 | confirmationCode := "0000" // Generate and send confirmation code using external API 28 | err := r.client.Set(ctx, phoneNumber, confirmationCode, time.Until(retryAt)).Err() 29 | if err != nil { 30 | return nil, err 31 | } 32 | timestamp := retryAt.UnixMilli() 33 | return ×tamp, nil 34 | } 35 | 36 | func (r *RepositoryImpl) VerifyPhoneNumberConfirmation(ctx context.Context, phoneNumber string, confirmationCode string) (*string, error) { 37 | if confirmationCode == r.client.Get(ctx, phoneNumber).Val() { 38 | err := r.client.Del(ctx, phoneNumber).Err() 39 | if err != nil { 40 | return nil, err 41 | } 42 | return &phoneNumber, nil 43 | } 44 | return nil, WrongConfirmationCode 45 | } 46 | -------------------------------------------------------------------------------- /confirmation/confirmation/service.go: -------------------------------------------------------------------------------- 1 | package confirmation 2 | 3 | import ( 4 | pb "confirmation/generated" 5 | "context" 6 | "google.golang.org/grpc/codes" 7 | "google.golang.org/grpc/status" 8 | ) 9 | 10 | type ServiceImpl struct { 11 | pb.UnimplementedConfirmationServiceServer 12 | useCase UseCase 13 | } 14 | 15 | func NewService(useCase UseCase) pb.ConfirmationServiceServer { 16 | return &ServiceImpl{useCase: useCase} 17 | } 18 | 19 | func (s *ServiceImpl) SendPhoneNumberConfirmation(ctx context.Context, request *pb.SendPhoneNumberConfirmationRequest) (*pb.SendPhoneNumberConfirmationResponse, error) { 20 | reqPhoneNumber := request.GetPhoneNumber() 21 | if reqPhoneNumber == "" { 22 | return nil, status.Error(codes.InvalidArgument, "Value cannot be empty") 23 | } 24 | retryAt, err := s.useCase.SendPhoneNumberConfirmation(ctx, reqPhoneNumber) 25 | if err != nil { 26 | return nil, err 27 | } 28 | return &pb.SendPhoneNumberConfirmationResponse{RetryAt: *retryAt}, nil 29 | } 30 | func (s *ServiceImpl) VerifyPhoneNumberConfirmation(ctx context.Context, request *pb.VerifyPhoneNumberConfirmationRequest) (*pb.VerifyPhoneNumberConfirmationResponse, error) { 31 | reqPhoneNumber := request.GetPhoneNumber() 32 | reqConfirmationCode := request.GetConfirmationCode() 33 | if reqPhoneNumber == "" || reqConfirmationCode == "" { 34 | return nil, status.Error(codes.InvalidArgument, "Value cannot be empty") 35 | } 36 | phoneNumber, err := s.useCase.VerifyPhoneNumberConfirmation(ctx, reqPhoneNumber, reqConfirmationCode) 37 | if err != nil { 38 | return nil, err 39 | } 40 | return &pb.VerifyPhoneNumberConfirmationResponse{PhoneNumber: *phoneNumber}, nil 41 | } 42 | -------------------------------------------------------------------------------- /confirmation/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | confirmation: 4 | build: . 5 | depends_on: 6 | - confirmation_redis 7 | volumes: 8 | - ./:/data 9 | restart: on-failure 10 | ports: 11 | - '8083:8083' 12 | confirmation_redis: 13 | image: redis:latest 14 | restart: on-failure 15 | ports: 16 | - '6379:6379' -------------------------------------------------------------------------------- /confirmation/go.mod: -------------------------------------------------------------------------------- 1 | module confirmation 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/go-redis/redis/v9 v9.0.0-rc.2 7 | github.com/spf13/viper v1.14.0 8 | google.golang.org/grpc v1.51.0 9 | google.golang.org/protobuf v1.28.1 10 | ) 11 | 12 | require ( 13 | github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect 14 | github.com/alicebob/miniredis v2.5.0+incompatible // indirect 15 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 16 | github.com/davecgh/go-spew v1.1.1 // indirect 17 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 18 | github.com/fsnotify/fsnotify v1.6.0 // indirect 19 | github.com/golang/protobuf v1.5.2 // indirect 20 | github.com/gomodule/redigo v1.8.9 // indirect 21 | github.com/hashicorp/hcl v1.0.0 // indirect 22 | github.com/magiconair/properties v1.8.6 // indirect 23 | github.com/mitchellh/mapstructure v1.5.0 // indirect 24 | github.com/pelletier/go-toml v1.9.5 // indirect 25 | github.com/pelletier/go-toml/v2 v2.0.5 // indirect 26 | github.com/pmezard/go-difflib v1.0.0 // indirect 27 | github.com/spf13/afero v1.9.2 // indirect 28 | github.com/spf13/cast v1.5.0 // indirect 29 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 30 | github.com/spf13/pflag v1.0.5 // indirect 31 | github.com/stretchr/objx v0.5.0 // indirect 32 | github.com/stretchr/testify v1.8.1 // indirect 33 | github.com/subosito/gotenv v1.4.1 // indirect 34 | github.com/yuin/gopher-lua v1.1.0 // indirect 35 | golang.org/x/net v0.2.0 // indirect 36 | golang.org/x/sys v0.2.0 // indirect 37 | golang.org/x/text v0.4.0 // indirect 38 | google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e // indirect 39 | gopkg.in/ini.v1 v1.67.0 // indirect 40 | gopkg.in/yaml.v2 v2.4.0 // indirect 41 | gopkg.in/yaml.v3 v3.0.1 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /confirmation/main/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "confirmation/config" 5 | "confirmation/confirmation" 6 | pb "confirmation/generated" 7 | "confirmation/server" 8 | "confirmation/store" 9 | "context" 10 | "flag" 11 | "fmt" 12 | "google.golang.org/grpc" 13 | "google.golang.org/grpc/codes" 14 | "google.golang.org/grpc/status" 15 | "log" 16 | ) 17 | 18 | func main() { 19 | productionMode := flag.Bool("production", false, "enable production mode") 20 | flag.Parse() 21 | cfgName := func() string { 22 | if *productionMode { 23 | return "prod" 24 | } 25 | return "dev" 26 | }() 27 | cfg, err := config.LoadConfig(cfgName) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | client := store.NewClient(context.Background(), fmt.Sprintf("%s:%s", cfg.RedisHostname, cfg.RedisPort)) 33 | defer client.Close() 34 | 35 | accountRepository := confirmation.NewRepository(client) 36 | accountUseCase := confirmation.NewUseCase(accountRepository) 37 | accountService := confirmation.NewService(accountUseCase) 38 | authInterceptor := server.NewInterceptor("Authorization", func(ctx context.Context, header string) error { 39 | if header != cfg.ApiKey { 40 | return status.Errorf(codes.Unauthenticated, "Invalid API key") 41 | } 42 | return nil 43 | }) 44 | 45 | grpcServer := server.Server{Address: cfg.ServerAddress} 46 | grpcServer.Launch(func(server *grpc.Server) { 47 | pb.RegisterConfirmationServiceServer(server, accountService) 48 | }, authInterceptor) 49 | } 50 | -------------------------------------------------------------------------------- /confirmation/server/interceptor.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "google.golang.org/grpc" 6 | "google.golang.org/grpc/codes" 7 | "google.golang.org/grpc/metadata" 8 | "google.golang.org/grpc/status" 9 | ) 10 | 11 | func NewInterceptor(name string, callback func(ctx context.Context, header string) error) grpc.ServerOption { 12 | return grpc.UnaryInterceptor(func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { 13 | md, ok := metadata.FromIncomingContext(ctx) 14 | if !ok { 15 | return nil, status.Errorf(codes.InvalidArgument, "Error reading metadata") 16 | } 17 | header := md.Get(name) 18 | if len(header) < 1 { 19 | return nil, status.Errorf(codes.InvalidArgument, "Error reading header") 20 | } 21 | if err := callback(ctx, header[0]); err != nil { 22 | return nil, status.Errorf(codes.Internal, "Error processing header") 23 | } 24 | return handler(ctx, req) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /confirmation/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "google.golang.org/grpc" 5 | "log" 6 | "net" 7 | ) 8 | 9 | type Server struct { 10 | Address string 11 | } 12 | 13 | func (s *Server) Launch(bind func(*grpc.Server), opts ...grpc.ServerOption) { 14 | server := grpc.NewServer(opts...) 15 | bind(server) 16 | listener, err := net.Listen("tcp", s.Address) 17 | if err != nil { 18 | log.Fatal(err) 19 | } else { 20 | log.Printf("Server started on: %s", s.Address) 21 | } 22 | if err = server.Serve(listener); err != nil { 23 | log.Fatal(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /confirmation/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "github.com/go-redis/redis/v9" 6 | "log" 7 | ) 8 | 9 | func NewClient(ctx context.Context, address string) *redis.Client { 10 | client := redis.NewClient(&redis.Options{Addr: address}) 11 | if _, err := client.Ping(ctx).Result(); err != nil { 12 | log.Fatal(err) 13 | } 14 | log.Printf("Connected to store: %s", address) 15 | return client 16 | } 17 | -------------------------------------------------------------------------------- /delivery/.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=delivery 2 | MONGO_HOSTNAME=0.0.0.0 3 | MONGO_PORT=27017 4 | DATABASE_NAME=delivery 5 | COLLECTION_ITEMS=items 6 | SERVER_HOSTNAME=0.0.0.0 7 | SERVER_PORT=8005 -------------------------------------------------------------------------------- /delivery/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /build/ 3 | /.idea/ 4 | /src/generated/ -------------------------------------------------------------------------------- /delivery/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm install 5 | COPY tsconfig*.json . 6 | COPY .env . 7 | COPY ./src ./src 8 | RUN apk update && apk add protoc 9 | CMD ["rm", "-r", "src/generated"] 10 | CMD ["mkdir", "-p", "src/generated"] 11 | CMD ["protoc", "--plugin=./node_modules/.bin/protoc-gen-ts_proto", "--ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true", "--ts_proto_out=./src/generated", "-I=proto proto/*.proto"] 12 | RUN npm run build 13 | RUN npm prune --production 14 | 15 | FROM node:alpine 16 | ENV REDIS_HOSTNAME=redis 17 | COPY --from=0 app/.env . 18 | COPY --from=0 app/build . 19 | COPY --from=0 app/node_modules /node_modules 20 | CMD ["node", "index.js"] -------------------------------------------------------------------------------- /delivery/README.md: -------------------------------------------------------------------------------- 1 | # Delivery microservice 2 | 3 | ___ 4 | 5 | ## Technologies: 6 | 7 | - Node.js 8 | - TypeScript 9 | - Inversify 10 | - gRPC 11 | - TS-Proto 12 | - MongoDB 13 | 14 | ## Setup: 15 | 16 | ``` 17 | docker-compose up --build -d 18 | ``` -------------------------------------------------------------------------------- /delivery/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | delivery: 4 | build: . 5 | depends_on: 6 | - delivery_mongo 7 | environment: 8 | - MONGO_HOSTNAME=mongodb 9 | volumes: 10 | - ./:/data 11 | restart: on-failure 12 | ports: 13 | - '8005:8005' 14 | delivery_mongo: 15 | image: mongo:latest 16 | restart: on-failure 17 | ports: 18 | - '27017:27017' -------------------------------------------------------------------------------- /delivery/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "delivery", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "protoc": "npx protoc --plugin=protoc-gen-ts_proto=.\\node_modules\\.bin\\protoc-gen-ts_proto.cmd --ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true --ts_proto_out=./src/generated -I=../proto delivery.proto", 7 | "protoc-debug": "npx rimraf src/generated && npx mkdirp src/generated && npm run protoc", 8 | "build": "tsc -p .", 9 | "dev": "ts-node src/index.ts", 10 | "test": "jest" 11 | }, 12 | "dependencies": { 13 | "@grpc/grpc-js": "^1.7.3", 14 | "dotenv": "^16.0.3", 15 | "fp-ts": "^2.13.1", 16 | "inversify": "^6.0.1", 17 | "mongodb": "^4.11.0", 18 | "reflect-metadata": "^0.1.13" 19 | }, 20 | "devDependencies": { 21 | "@types/jest": "^29.2.2", 22 | "@types/node": "^18.11.9", 23 | "grpc-tools": "^1.11.3", 24 | "ts-jest": "^29.0.3", 25 | "ts-node": "^10.9.1", 26 | "ts-proto": "^1.132.1", 27 | "typescript": "^4.8.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /delivery/src/app/index.ts: -------------------------------------------------------------------------------- 1 | export const createApplication = (initialize: () => Promise, execute: () => Promise) => { 2 | Promise.all([initialize(), execute()]) 3 | .then(() => console.log("Successfully launched application.")) 4 | .catch(console.error); 5 | }; -------------------------------------------------------------------------------- /delivery/src/config/Config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from "inversify"; 2 | 3 | @injectable() 4 | export class Config { 5 | readonly MONGO_URL = `mongodb://${process.env.MONGO_HOSTNAME}:${process.env.MONGO_PORT}`; 6 | readonly SERVER_URL = `${process.env.SERVER_HOSTNAME}:${process.env.SERVER_PORT}`; 7 | readonly DATABASE_NAME = process.env.DATABASE_NAME; 8 | readonly COLLECTION_ITEMS = process.env.COLLECTION_ITEMS; 9 | } -------------------------------------------------------------------------------- /delivery/src/database/Database.ts: -------------------------------------------------------------------------------- 1 | import {Document, MongoClient} from "mongodb"; 2 | import {inject, injectable} from "inversify"; 3 | import {Types} from "../di/types"; 4 | import {Config} from "../config/Config"; 5 | 6 | @injectable() 7 | export class Database { 8 | client: MongoClient | null = null; 9 | 10 | constructor(@inject(Types.app.config) private readonly config: Config) { 11 | } 12 | 13 | open = async () => new MongoClient(this.config.MONGO_URL).connect().then(client => { 14 | this.client = client; 15 | console.log(`Connected to database: ${this.config.MONGO_URL}`); 16 | }).catch(console.error); 17 | 18 | close = () => this.client?.close().then(() => { 19 | console.log(`Disconnected from database: ${this.config.MONGO_URL}`); 20 | this.client = null; 21 | }).catch(console.error); 22 | 23 | collection = (name: string) => this.client?.db(this.config.DATABASE_NAME).collection(name); 24 | } -------------------------------------------------------------------------------- /delivery/src/delivery/CancelDelivery.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Types} from "../di/types"; 4 | import {DeliveryRepository} from "./DeliveryRepository"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {pipe} from "fp-ts/function"; 7 | import {taskEither as TE} from "fp-ts"; 8 | import {DeliveryError} from "./DeliveryError"; 9 | import {DeliveryStatus} from "./DeliveryStatus"; 10 | import {Delivery} from "./Delivery"; 11 | 12 | @injectable() 13 | export class CancelDelivery extends UseCase { 14 | constructor(@inject(Types.delivery.repository) private readonly repository: DeliveryRepository) { 15 | super(); 16 | } 17 | 18 | execute = (arg: string): TaskEither => pipe( 19 | this.repository.getDeliveryById(arg), 20 | TE.chain(TE.fromNullable(DeliveryError.NotFound)), 21 | TE.chain(delivery => this.repository.updateDelivery({...delivery, status: DeliveryStatus.Canceled})), 22 | TE.chain(TE.fromNullable(DeliveryError.NotFound)), 23 | TE.map(delivery => delivery) 24 | ); 25 | } -------------------------------------------------------------------------------- /delivery/src/delivery/CompleteDelivery.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Types} from "../di/types"; 4 | import {DeliveryRepository} from "./DeliveryRepository"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {pipe} from "fp-ts/function"; 7 | import {taskEither as TE} from "fp-ts"; 8 | import {DeliveryError} from "./DeliveryError"; 9 | import {DeliveryStatus} from "./DeliveryStatus"; 10 | import {Delivery} from "./Delivery"; 11 | 12 | @injectable() 13 | export class CompleteDelivery extends UseCase { 14 | constructor(@inject(Types.delivery.repository) private readonly repository: DeliveryRepository) { 15 | super(); 16 | } 17 | 18 | execute = (arg: string): TaskEither => pipe( 19 | this.repository.getDeliveryById(arg), 20 | TE.chain(TE.fromNullable(DeliveryError.NotFound)), 21 | TE.chain(delivery => this.repository.updateDelivery({...delivery, status: DeliveryStatus.Completed})), 22 | TE.chain(TE.fromNullable(DeliveryError.NotFound)), 23 | TE.map(delivery => delivery) 24 | ); 25 | } -------------------------------------------------------------------------------- /delivery/src/delivery/Delivery.ts: -------------------------------------------------------------------------------- 1 | import {DeliveryItem} from "./DeliveryItem"; 2 | import {DeliveryStatus} from "./DeliveryStatus"; 3 | 4 | export type Delivery = { 5 | id: string; 6 | orderId: string; 7 | status: DeliveryStatus; 8 | details: string; 9 | items: DeliveryItem[]; 10 | address: string; 11 | courierId: string; 12 | startedAt: number; 13 | deliveredBy: number; 14 | }; -------------------------------------------------------------------------------- /delivery/src/delivery/DeliveryError.ts: -------------------------------------------------------------------------------- 1 | export namespace DeliveryError { 2 | export const NotFound = new Error("Delivery not found"); 3 | } -------------------------------------------------------------------------------- /delivery/src/delivery/DeliveryItem.ts: -------------------------------------------------------------------------------- 1 | export type DeliveryItem = { 2 | id: string; 3 | sku: string; 4 | quantity: number; 5 | price: string; 6 | }; -------------------------------------------------------------------------------- /delivery/src/delivery/DeliveryItemMapper.ts: -------------------------------------------------------------------------------- 1 | import {DeliveryItem as DeliveryItemMessage} from "../generated/delivery"; 2 | import {DeliveryItem} from "./DeliveryItem"; 3 | 4 | export namespace DeliveryItemMapper { 5 | export const entityToMessage = (entity: DeliveryItem): DeliveryItemMessage => ({ 6 | id: entity.id, 7 | sku: entity.sku, 8 | quantity: entity.quantity, 9 | price: entity.price 10 | }); 11 | export const messageToEntity = (message: DeliveryItemMessage): DeliveryItem => ({ 12 | id: message.id, 13 | sku: message.sku, 14 | quantity: message.quantity, 15 | price: message.price 16 | }); 17 | } -------------------------------------------------------------------------------- /delivery/src/delivery/DeliveryMapper.ts: -------------------------------------------------------------------------------- 1 | import {Delivery as DeliveryMessage} from "../generated/delivery"; 2 | import {Delivery} from "./Delivery"; 3 | import {DeliveryItemMapper} from "./DeliveryItemMapper"; 4 | 5 | export namespace DeliveryMapper { 6 | export const entityToMessage = (entity: Delivery): DeliveryMessage => ({ 7 | id: entity.id, 8 | orderId: entity.orderId, 9 | status: entity.status.valueOf(), 10 | details: entity.details, 11 | items: entity.items.map(DeliveryItemMapper.entityToMessage), 12 | address: entity.address, 13 | courierId: entity.courierId, 14 | startedAt: entity.startedAt, 15 | deliveredBy: entity.deliveredBy 16 | }); 17 | export const messageToEntity = (message: DeliveryMessage): Delivery => ({ 18 | id: message.id, 19 | orderId: message.orderId, 20 | status: message.status.valueOf(), 21 | details: message.details, 22 | items: message.items.map(DeliveryItemMapper.messageToEntity), 23 | address: message.address, 24 | courierId: message.courierId, 25 | startedAt: message.startedAt, 26 | deliveredBy: message.deliveredBy 27 | }); 28 | } -------------------------------------------------------------------------------- /delivery/src/delivery/DeliveryStatus.ts: -------------------------------------------------------------------------------- 1 | export enum DeliveryStatus { 2 | Started, Canceled, Completed 3 | } -------------------------------------------------------------------------------- /delivery/src/delivery/GetDeliveriesByCourierId.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Delivery} from "./Delivery"; 4 | import {Types} from "../di/types"; 5 | import {DeliveryRepository} from "./DeliveryRepository"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {DeliveryError} from "./DeliveryError"; 9 | import {taskEither as TE} from "fp-ts"; 10 | 11 | @injectable() 12 | export class GetDeliveriesByCourierId extends UseCase<[string, number, number], Delivery[]> { 13 | constructor(@inject(Types.delivery.repository) private readonly repository: DeliveryRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: [string, number, number]): TaskEither => pipe( 18 | this.repository.getDeliveriesByCourierId(...arg), 19 | TE.chain(TE.fromNullable(DeliveryError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /delivery/src/delivery/GetDeliveriesByOrderId.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Delivery} from "./Delivery"; 4 | import {Types} from "../di/types"; 5 | import {DeliveryRepository} from "./DeliveryRepository"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {DeliveryError} from "./DeliveryError"; 10 | 11 | @injectable() 12 | export class GetDeliveriesByOrderId extends UseCase<[string, number, number], Delivery[]> { 13 | constructor(@inject(Types.delivery.repository) private readonly repository: DeliveryRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: [string, number, number]): TaskEither => pipe( 18 | this.repository.getDeliveriesByOrderId(...arg), 19 | TE.chain(TE.fromNullable(DeliveryError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /delivery/src/delivery/GetDeliveryById.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Delivery} from "./Delivery"; 4 | import {Types} from "../di/types"; 5 | import {DeliveryRepository} from "./DeliveryRepository"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {DeliveryError} from "./DeliveryError"; 10 | 11 | @injectable() 12 | export class GetDeliveryById extends UseCase { 13 | constructor(@inject(Types.delivery.repository) private readonly repository: DeliveryRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: string): TaskEither => pipe( 18 | this.repository.getDeliveryById(arg), 19 | TE.chain(TE.fromNullable(DeliveryError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /delivery/src/delivery/RemoveDelivery.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Types} from "../di/types"; 4 | import {DeliveryRepository} from "./DeliveryRepository"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {pipe} from "fp-ts/function"; 7 | import {taskEither as TE} from "fp-ts"; 8 | import {DeliveryError} from "./DeliveryError"; 9 | 10 | @injectable() 11 | export class RemoveDelivery extends UseCase { 12 | constructor(@inject(Types.delivery.repository) private readonly repository: DeliveryRepository) { 13 | super(); 14 | } 15 | 16 | execute = (arg: string): TaskEither => pipe( 17 | this.repository.removeDelivery(arg), 18 | TE.chain(TE.fromNullable(DeliveryError.NotFound)) 19 | ); 20 | } -------------------------------------------------------------------------------- /delivery/src/delivery/StartDelivery.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {Types} from "../di/types"; 3 | import {DeliveryRepository} from "./DeliveryRepository"; 4 | import {UseCase} from "../interactor/UseCase"; 5 | import {Delivery} from "./Delivery"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {DeliveryError} from "./DeliveryError"; 10 | 11 | @injectable() 12 | export class StartDelivery extends UseCase { 13 | constructor(@inject(Types.delivery.repository) private readonly repository: DeliveryRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: Delivery): TaskEither => pipe( 18 | this.repository.createDelivery(arg), 19 | TE.chain(TE.fromNullable(DeliveryError.NotFound)), 20 | TE.chain(this.repository.getDeliveryById), 21 | TE.chain(TE.fromNullable(DeliveryError.NotFound)) 22 | ); 23 | } -------------------------------------------------------------------------------- /delivery/src/delivery/UpdateDelivery.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Delivery} from "./Delivery"; 4 | import {Types} from "../di/types"; 5 | import {DeliveryRepository} from "./DeliveryRepository"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {DeliveryError} from "./DeliveryError"; 10 | 11 | @injectable() 12 | export class UpdateDelivery extends UseCase { 13 | constructor(@inject(Types.delivery.repository) private readonly repository: DeliveryRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: Delivery): TaskEither => pipe( 18 | this.repository.updateDelivery(arg), 19 | TE.chain(TE.fromNullable(DeliveryError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /delivery/src/di/types.ts: -------------------------------------------------------------------------------- 1 | export namespace Types { 2 | export const app = { 3 | config: Symbol.for("config"), 4 | database: Symbol.for("database"), 5 | server: Symbol.for("server") 6 | }; 7 | export const delivery = { 8 | collection: Symbol.for("deliveryCollection"), 9 | repository: Symbol.for("deliveryRepository"), 10 | service: Symbol.for("deliveryService"), 11 | startDelivery: Symbol.for("startDelivery"), 12 | getDeliveryById: Symbol.for("getDeliveryById"), 13 | getDeliveriesByCourierId: Symbol.for("getDeliveriesByCourierId"), 14 | getDeliveriesByOrderId: Symbol.for("getDeliveriesByOrderId"), 15 | updateDelivery: Symbol.for("updateDelivery"), 16 | completeDelivery: Symbol.for("completeDelivery"), 17 | cancelDelivery: Symbol.for("cancelDelivery"), 18 | removeDelivery: Symbol.for("removeDelivery") 19 | }; 20 | } -------------------------------------------------------------------------------- /delivery/src/index.ts: -------------------------------------------------------------------------------- 1 | import {Module} from "./di/module"; 2 | import {Types} from "./di/types"; 3 | import {Server} from "./server/Server"; 4 | import * as dotenv from "dotenv"; 5 | import {DeliveryServiceServer, DeliveryServiceService} from "./generated/delivery"; 6 | import {Database} from "./database/Database"; 7 | import {createApplication} from "./app"; 8 | 9 | const initialize = async () => { 10 | dotenv.config(); 11 | Module.initModules(); 12 | }; 13 | 14 | const execute = async () => { 15 | await Module.container.get(Types.app.database).open(); 16 | await Module.container.get(Types.app.server).launch(server => { 17 | server.addService(DeliveryServiceService, Module.container.get(Types.delivery.service)); 18 | }); 19 | }; 20 | 21 | createApplication(initialize, execute); -------------------------------------------------------------------------------- /delivery/src/interactor/UseCase.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | 3 | export abstract class UseCase { 4 | abstract execute(arg: T): TaskEither 5 | } -------------------------------------------------------------------------------- /delivery/src/response/ResponseError.ts: -------------------------------------------------------------------------------- 1 | export namespace ResponseError { 2 | export const map = new Error("Unable to map value"); 3 | } -------------------------------------------------------------------------------- /delivery/src/response/index.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | import {flow} from "fp-ts/function"; 3 | import {either as E} from "fp-ts"; 4 | import {ResponseError} from "./ResponseError"; 5 | 6 | export const response = (input: TaskEither, callback: (e: Error | null, r: R | null) => void, map: (t: T) => R): void => { 7 | input().then(flow(E.chain((t: T) => E.tryCatch(() => map(t), () => ResponseError.map)), E.fold((e: Error) => callback(e, null), (r: R) => callback(null, r)))).catch(e => callback(e, null)); 8 | } -------------------------------------------------------------------------------- /delivery/src/server/Server.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {Types} from "../di/types"; 3 | import {Config} from "../config/Config"; 4 | import {Server as GrpcServer, ServerCredentials} from "@grpc/grpc-js"; 5 | 6 | @injectable() 7 | export class Server { 8 | constructor(@inject(Types.app.config) private readonly config: Config) { 9 | } 10 | 11 | async launch(bind: (server: GrpcServer) => void) { 12 | const server: GrpcServer = new GrpcServer(); 13 | bind(server); 14 | server.bindAsync(this.config.SERVER_URL, ServerCredentials.createInsecure(), (error, port) => { 15 | if (error) console.error(error); 16 | server.start(); 17 | console.log(`Server running on port: ${port}`); 18 | }); 19 | }; 20 | } -------------------------------------------------------------------------------- /docker-compose.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for folder in ./*; do 4 | if [ -f "$folder/docker-compose.yml" ]; then 5 | cd "$folder" || exit 6 | docker-compose up -d 7 | cd - || exit 8 | fi 9 | done 10 | -------------------------------------------------------------------------------- /gateway/.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /generated/ -------------------------------------------------------------------------------- /gateway/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine 2 | WORKDIR /build 3 | COPY . . 4 | RUN apk update && apk add protoc 5 | CMD ["rm","-r","generated"] 6 | CMD ["mkdir","-p","generated"] 7 | CMD ["protoc", "--go_out=generated", "--go-grpc_out=generated", "--proto_path=proto", "proto/*.proto"] 8 | RUN go get -d -v ./... 9 | RUN go build -o target ./main 10 | 11 | FROM alpine:latest 12 | COPY --from=0 build/config/prod.env . 13 | COPY --from=0 build/target . 14 | CMD ["./target","-production"] -------------------------------------------------------------------------------- /gateway/authentication/client.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | import ( 4 | pb "gateway/generated" 5 | "google.golang.org/grpc" 6 | "google.golang.org/grpc/credentials/insecure" 7 | "log" 8 | ) 9 | 10 | func NewClient(address string) pb.AuthenticationServiceClient { 11 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 12 | if err != nil { 13 | log.Fatal(err) 14 | } 15 | return pb.NewAuthenticationServiceClient(connection) 16 | } 17 | -------------------------------------------------------------------------------- /gateway/authentication/service.go: -------------------------------------------------------------------------------- 1 | package authentication 2 | 3 | import ( 4 | "context" 5 | pb "gateway/generated" 6 | "google.golang.org/grpc" 7 | "google.golang.org/grpc/credentials/insecure" 8 | "log" 9 | ) 10 | 11 | type ServiceImpl struct { 12 | pb.UnimplementedAuthenticationServiceServer 13 | Client pb.AuthenticationServiceClient 14 | } 15 | 16 | func NewService(address string) pb.AuthenticationServiceServer { 17 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | return &ServiceImpl{Client: pb.NewAuthenticationServiceClient(connection)} 22 | } 23 | 24 | func (s *ServiceImpl) SignInByPhoneNumber(ctx context.Context, request *pb.SignInByPhoneNumberRequest) (*pb.SignInByPhoneNumberResponse, error) { 25 | return s.Client.SignInByPhoneNumber(ctx, request) 26 | } 27 | 28 | func (s *ServiceImpl) ConfirmPhoneNumber(ctx context.Context, request *pb.ConfirmPhoneNumberRequest) (*pb.ConfirmPhoneNumberResponse, error) { 29 | return s.Client.ConfirmPhoneNumber(ctx, request) 30 | } 31 | 32 | func (s *ServiceImpl) SignOut(ctx context.Context, request *pb.SignOutRequest) (*pb.SignOutResponse, error) { 33 | return s.Client.SignOut(ctx, request) 34 | } 35 | 36 | func (s *ServiceImpl) RefreshToken(ctx context.Context, request *pb.RefreshTokenRequest) (*pb.RefreshTokenResponse, error) { 37 | return s.Client.RefreshToken(ctx, request) 38 | } 39 | 40 | func (s *ServiceImpl) VerifyAccess(ctx context.Context, request *pb.VerifyAccessRequest) (*pb.VerifyAccessResponse, error) { 41 | return s.Client.VerifyAccess(ctx, request) 42 | } 43 | -------------------------------------------------------------------------------- /gateway/cart/service.go: -------------------------------------------------------------------------------- 1 | package cart 2 | 3 | import ( 4 | "context" 5 | pb "gateway/generated" 6 | "google.golang.org/grpc" 7 | "google.golang.org/grpc/credentials/insecure" 8 | "log" 9 | ) 10 | 11 | type ServiceImpl struct { 12 | pb.UnimplementedCartServiceServer 13 | Client pb.CartServiceClient 14 | } 15 | 16 | func NewService(address string) pb.CartServiceServer { 17 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | return &ServiceImpl{Client: pb.NewCartServiceClient(connection)} 22 | } 23 | 24 | func (s *ServiceImpl) GetCart(ctx context.Context, request *pb.GetCartRequest) (*pb.GetCartResponse, error) { 25 | return s.Client.GetCart(ctx, request) 26 | } 27 | 28 | func (s *ServiceImpl) ClearCart(ctx context.Context, request *pb.ClearCartRequest) (*pb.ClearCartResponse, error) { 29 | return s.Client.ClearCart(ctx, request) 30 | } 31 | 32 | func (s *ServiceImpl) IncreaseItemQuantity(ctx context.Context, request *pb.IncreaseItemQuantityRequest) (*pb.IncreaseItemQuantityResponse, error) { 33 | return s.Client.IncreaseItemQuantity(ctx, request) 34 | } 35 | 36 | func (s *ServiceImpl) DecreaseItemQuantity(ctx context.Context, request *pb.DecreaseItemQuantityRequest) (*pb.DecreaseItemQuantityResponse, error) { 37 | return s.Client.DecreaseItemQuantity(ctx, request) 38 | } 39 | -------------------------------------------------------------------------------- /gateway/catalog/service.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "context" 5 | pb "gateway/generated" 6 | "google.golang.org/grpc" 7 | "google.golang.org/grpc/credentials/insecure" 8 | "log" 9 | ) 10 | 11 | type ServiceImpl struct { 12 | pb.UnimplementedCatalogServiceServer 13 | Client pb.CatalogServiceClient 14 | } 15 | 16 | func NewService(address string) pb.CatalogServiceServer { 17 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | return &ServiceImpl{Client: pb.NewCatalogServiceClient(connection)} 22 | } 23 | 24 | func (s *ServiceImpl) AddCatalogItem(ctx context.Context, request *pb.AddCatalogItemRequest) (*pb.AddCatalogItemResponse, error) { 25 | return s.Client.AddCatalogItem(ctx, request) 26 | } 27 | 28 | func (s *ServiceImpl) GetCatalogItemById(ctx context.Context, request *pb.GetCatalogItemByIdRequest) (*pb.GetCatalogItemByIdResponse, error) { 29 | return s.Client.GetCatalogItemById(ctx, request) 30 | } 31 | 32 | func (s *ServiceImpl) GetCatalogItemsByTags(ctx context.Context, request *pb.GetCatalogItemsByTagsRequest) (*pb.GetCatalogItemsByTagsResponse, error) { 33 | return s.Client.GetCatalogItemsByTags(ctx, request) 34 | } 35 | 36 | func (s *ServiceImpl) UpdateCatalogItem(ctx context.Context, request *pb.UpdateCatalogItemRequest) (*pb.UpdateCatalogItemResponse, error) { 37 | return s.Client.UpdateCatalogItem(ctx, request) 38 | } 39 | 40 | func (s *ServiceImpl) RemoveCatalogItem(ctx context.Context, request *pb.RemoveCatalogItemRequest) (*pb.RemoveCatalogItemResponse, error) { 41 | return s.Client.RemoveCatalogItem(ctx, request) 42 | } 43 | -------------------------------------------------------------------------------- /gateway/category/service.go: -------------------------------------------------------------------------------- 1 | package category 2 | 3 | import ( 4 | "context" 5 | pb "gateway/generated" 6 | "google.golang.org/grpc" 7 | "google.golang.org/grpc/credentials/insecure" 8 | "log" 9 | ) 10 | 11 | type ServiceImpl struct { 12 | pb.UnimplementedCategoryServiceServer 13 | Client pb.CategoryServiceClient 14 | } 15 | 16 | func NewService(address string) pb.CategoryServiceServer { 17 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | return &ServiceImpl{Client: pb.NewCategoryServiceClient(connection)} 22 | } 23 | 24 | func (s *ServiceImpl) AddCategory(ctx context.Context, request *pb.AddCategoryRequest) (*pb.AddCategoryResponse, error) { 25 | return s.Client.AddCategory(ctx, request) 26 | } 27 | 28 | func (s *ServiceImpl) GetCategoryById(ctx context.Context, request *pb.GetCategoryByIdRequest) (*pb.GetCategoryByIdResponse, error) { 29 | return s.Client.GetCategoryById(ctx, request) 30 | } 31 | 32 | func (s *ServiceImpl) GetCategories(ctx context.Context, request *pb.GetCategoriesRequest) (*pb.GetCategoriesResponse, error) { 33 | return s.Client.GetCategories(ctx, request) 34 | } 35 | 36 | func (s *ServiceImpl) GetCategoriesByTags(ctx context.Context, request *pb.GetCategoriesByTagsRequest) (*pb.GetCategoriesByTagsResponse, error) { 37 | return s.Client.GetCategoriesByTags(ctx, request) 38 | } 39 | 40 | func (s *ServiceImpl) UpdateCategory(ctx context.Context, request *pb.UpdateCategoryRequest) (*pb.UpdateCategoryResponse, error) { 41 | return s.Client.UpdateCategory(ctx, request) 42 | } 43 | 44 | func (s *ServiceImpl) RemoveCategory(ctx context.Context, request *pb.RemoveCategoryRequest) (*pb.RemoveCategoryResponse, error) { 45 | return s.Client.RemoveCategory(ctx, request) 46 | } 47 | -------------------------------------------------------------------------------- /gateway/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | "log" 6 | ) 7 | 8 | type Config struct { 9 | ServerAddress string `mapstructure:"SERVER_ADDRESS"` 10 | CategoryAddress string `mapstructure:"CATEGORY_ADDRESS"` 11 | CatalogAddress string `mapstructure:"CATALOG_ADDRESS"` 12 | CartAddress string `mapstructure:"CART_ADDRESS"` 13 | OrderAddress string `mapstructure:"ORDER_ADDRESS"` 14 | DeliveryAddress string `mapstructure:"DELIVERY_ADDRESS"` 15 | PaymentAddress string `mapstructure:"PAYMENT_ADDRESS"` 16 | SearchAddress string `mapstructure:"SEARCH_ADDRESS"` 17 | ProfileAddress string `mapstructure:"PROFILE_ADDRESS"` 18 | PromoAddress string `mapstructure:"PROMO_ADDRESS"` 19 | AuthenticationAddress string `mapstructure:"AUTHENTICATION_ADDRESS"` 20 | } 21 | 22 | func LoadConfig(name string) (config Config, err error) { 23 | viper.AddConfigPath(".") 24 | viper.AddConfigPath("./config") 25 | viper.SetConfigType("env") 26 | viper.SetConfigName(name) 27 | if err = viper.ReadInConfig(); err != nil { 28 | log.Fatal(err) 29 | } 30 | if err = viper.Unmarshal(&config); err != nil { 31 | log.Fatal(err) 32 | } 33 | return 34 | } 35 | -------------------------------------------------------------------------------- /gateway/config/dev.env: -------------------------------------------------------------------------------- 1 | SERVER_ADDRESS=0.0.0.0:8000 2 | CATEGORY_ADDRESS=0.0.0.0:8001 3 | CATALOG_ADDRESS=0.0.0.0:8002 4 | CART_ADDRESS=0.0.0.0:8003 5 | ORDER_ADDRESS=0.0.0.0:8004 6 | DELIVERY_ADDRESS=0.0.0.0:8005 7 | PAYMENT_ADDRESS=0.0.0.0:8006 8 | SEARCH_ADDRESS=0.0.0.0:8007 9 | PROFILE_ADDRESS=0.0.0.0:8008 10 | PROMO_ADDRESS=0.0.0.0:8009 11 | AUTHENTICATION_ADDRESS=0.0.0.0:8080 12 | SIMULATION_ADDRESS=0.0.0.0:8090 -------------------------------------------------------------------------------- /gateway/config/prod.env: -------------------------------------------------------------------------------- 1 | SERVER_ADDRESS=0.0.0.0:8000 2 | CATEGORY_ADDRESS=0.0.0.0:8001 3 | CATALOG_ADDRESS=0.0.0.0:8002 4 | CART_ADDRESS=0.0.0.0:8003 5 | ORDER_ADDRESS=0.0.0.0:8004 6 | DELIVERY_ADDRESS=0.0.0.0:8005 7 | PAYMENT_ADDRESS=0.0.0.0:8006 8 | SEARCH_ADDRESS=0.0.0.0:8007 9 | PROFILE_ADDRESS=0.0.0.0:8008 10 | PROMO_ADDRESS=0.0.0.0:8009 11 | AUTHENTICATION_ADDRESS=0.0.0.0:8080 12 | SIMULATION_ADDRESS=0.0.0.0:8090 -------------------------------------------------------------------------------- /gateway/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | gateway: 4 | build: . 5 | volumes: 6 | - ./:/data 7 | restart: on-failure 8 | ports: 9 | - '8000:8000' -------------------------------------------------------------------------------- /gateway/go.mod: -------------------------------------------------------------------------------- 1 | module gateway 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/spf13/viper v1.14.0 7 | google.golang.org/grpc v1.51.0 8 | ) 9 | 10 | require ( 11 | github.com/fsnotify/fsnotify v1.6.0 // indirect 12 | github.com/golang/protobuf v1.5.2 // indirect 13 | github.com/hashicorp/hcl v1.0.0 // indirect 14 | github.com/magiconair/properties v1.8.6 // indirect 15 | github.com/mitchellh/mapstructure v1.5.0 // indirect 16 | github.com/pelletier/go-toml v1.9.5 // indirect 17 | github.com/pelletier/go-toml/v2 v2.0.5 // indirect 18 | github.com/spf13/afero v1.9.2 // indirect 19 | github.com/spf13/cast v1.5.0 // indirect 20 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 21 | github.com/spf13/pflag v1.0.5 // indirect 22 | github.com/subosito/gotenv v1.4.1 // indirect 23 | golang.org/x/net v0.0.0-20221014081412-f15817d10f9b // indirect 24 | golang.org/x/sys v0.0.0-20220908164124-27713097b956 // indirect 25 | golang.org/x/text v0.4.0 // indirect 26 | google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e // indirect 27 | google.golang.org/protobuf v1.28.1 // indirect 28 | gopkg.in/ini.v1 v1.67.0 // indirect 29 | gopkg.in/yaml.v2 v2.4.0 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /gateway/order/service.go: -------------------------------------------------------------------------------- 1 | package order 2 | 3 | import ( 4 | "context" 5 | pb "gateway/generated" 6 | "google.golang.org/grpc" 7 | "google.golang.org/grpc/credentials/insecure" 8 | "log" 9 | ) 10 | 11 | type ServiceImpl struct { 12 | pb.UnimplementedOrderServiceServer 13 | Client pb.OrderServiceClient 14 | } 15 | 16 | func NewService(address string) pb.OrderServiceServer { 17 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | return &ServiceImpl{Client: pb.NewOrderServiceClient(connection)} 22 | } 23 | 24 | func (s *ServiceImpl) CreateOrder(ctx context.Context, request *pb.CreateOrderRequest) (*pb.CreateOrderResponse, error) { 25 | return s.Client.CreateOrder(ctx, request) 26 | } 27 | func (s *ServiceImpl) GetOrderById(ctx context.Context, request *pb.GetOrderByIdRequest) (*pb.GetOrderByIdResponse, error) { 28 | return s.Client.GetOrderById(ctx, request) 29 | } 30 | func (s *ServiceImpl) GetCustomerOrders(ctx context.Context, request *pb.GetCustomerOrdersRequest) (*pb.GetCustomerOrdersResponse, error) { 31 | return s.Client.GetCustomerOrders(ctx, request) 32 | } 33 | func (s *ServiceImpl) UpdateOrder(ctx context.Context, request *pb.UpdateOrderRequest) (*pb.UpdateOrderResponse, error) { 34 | return s.Client.UpdateOrder(ctx, request) 35 | } 36 | func (s *ServiceImpl) DeleteOrder(ctx context.Context, request *pb.DeleteOrderRequest) (*pb.DeleteOrderResponse, error) { 37 | return s.Client.DeleteOrder(ctx, request) 38 | } 39 | -------------------------------------------------------------------------------- /gateway/profile/service.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "context" 5 | pb "gateway/generated" 6 | "google.golang.org/grpc" 7 | "google.golang.org/grpc/credentials/insecure" 8 | "log" 9 | ) 10 | 11 | type ServiceImpl struct { 12 | pb.UnimplementedProfileServiceServer 13 | Client pb.ProfileServiceClient 14 | } 15 | 16 | func NewService(address string) pb.ProfileServiceServer { 17 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | return &ServiceImpl{Client: pb.NewProfileServiceClient(connection)} 22 | } 23 | 24 | func (s *ServiceImpl) CreateProfile(ctx context.Context, request *pb.CreateProfileRequest) (*pb.CreateProfileResponse, error) { 25 | return s.Client.CreateProfile(ctx, request) 26 | } 27 | 28 | func (s *ServiceImpl) GetProfileById(ctx context.Context, request *pb.GetProfileByIdRequest) (*pb.GetProfileByIdResponse, error) { 29 | return s.Client.GetProfileById(ctx, request) 30 | } 31 | 32 | func (s *ServiceImpl) UpdateProfile(ctx context.Context, request *pb.UpdateProfileRequest) (*pb.UpdateProfileResponse, error) { 33 | return s.Client.UpdateProfile(ctx, request) 34 | } 35 | 36 | func (s *ServiceImpl) RemoveProfile(ctx context.Context, request *pb.RemoveProfileRequest) (*pb.RemoveProfileResponse, error) { 37 | return s.Client.RemoveProfile(ctx, request) 38 | } 39 | -------------------------------------------------------------------------------- /gateway/promo/service.go: -------------------------------------------------------------------------------- 1 | package promo 2 | 3 | import ( 4 | "context" 5 | pb "gateway/generated" 6 | "google.golang.org/grpc" 7 | "google.golang.org/grpc/credentials/insecure" 8 | "log" 9 | ) 10 | 11 | type ServiceImpl struct { 12 | pb.UnimplementedPromoServiceServer 13 | Client pb.PromoServiceClient 14 | } 15 | 16 | func NewService(address string) pb.PromoServiceServer { 17 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | return &ServiceImpl{Client: pb.NewPromoServiceClient(connection)} 22 | } 23 | 24 | func (s *ServiceImpl) InsertPromo(ctx context.Context, request *pb.InsertPromoRequest) (*pb.InsertPromoResponse, error) { 25 | return s.Client.InsertPromo(ctx, request) 26 | } 27 | 28 | func (s *ServiceImpl) GetPromo(ctx context.Context, request *pb.GetPromoRequest) (*pb.GetPromoResponse, error) { 29 | return s.Client.GetPromo(ctx, request) 30 | } 31 | 32 | func (s *ServiceImpl) RemovePromo(ctx context.Context, request *pb.RemovePromoRequest) (*pb.RemovePromoResponse, error) { 33 | return s.Client.RemovePromo(ctx, request) 34 | } 35 | -------------------------------------------------------------------------------- /gateway/search/search.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | pb "gateway/generated" 6 | "google.golang.org/grpc" 7 | "google.golang.org/grpc/credentials/insecure" 8 | "log" 9 | ) 10 | 11 | type ServiceImpl struct { 12 | pb.UnimplementedSearchServiceServer 13 | Client pb.SearchServiceClient 14 | } 15 | 16 | func NewService(address string) pb.SearchServiceServer { 17 | connection, err := grpc.Dial(address, grpc.WithTransportCredentials(insecure.NewCredentials())) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | return &ServiceImpl{Client: pb.NewSearchServiceClient(connection)} 22 | } 23 | 24 | func (s *ServiceImpl) Search(ctx context.Context, request *pb.SearchRequest) (*pb.SearchResponse, error) { 25 | return s.Client.Search(ctx, request) 26 | } 27 | -------------------------------------------------------------------------------- /gateway/server/interceptor.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "google.golang.org/grpc" 6 | "google.golang.org/grpc/codes" 7 | "google.golang.org/grpc/metadata" 8 | "google.golang.org/grpc/status" 9 | ) 10 | 11 | func NewInterceptor(name string, callback func(ctx context.Context, info *grpc.UnaryServerInfo, header string) error) grpc.ServerOption { 12 | return grpc.UnaryInterceptor(func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { 13 | md, ok := metadata.FromIncomingContext(ctx) 14 | if !ok { 15 | return nil, status.Errorf(codes.InvalidArgument, "Error reading metadata") 16 | } 17 | header := md.Get(name) 18 | if len(header) < 1 { 19 | return nil, status.Errorf(codes.InvalidArgument, "Error reading header") 20 | } 21 | if err := callback(ctx, info, header[0]); err != nil { 22 | return nil, status.Errorf(codes.Internal, "Error processing header") 23 | } 24 | return handler(ctx, req) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /gateway/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "google.golang.org/grpc" 5 | "log" 6 | "net" 7 | ) 8 | 9 | type Server struct { 10 | Address string 11 | } 12 | 13 | func (s *Server) Launch(bind func(*grpc.Server), opts ...grpc.ServerOption) { 14 | server := grpc.NewServer(opts...) 15 | bind(server) 16 | listener, err := net.Listen("tcp", s.Address) 17 | if err != nil { 18 | log.Fatal(err) 19 | } else { 20 | log.Printf("Server started on: %s", s.Address) 21 | } 22 | if err = server.Serve(listener); err != nil { 23 | log.Fatal(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /media/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numq/ecommerce-backend/62cda50e1cc9a70f556687f2091dfcaebf1ce8e7/media/.gitkeep -------------------------------------------------------------------------------- /media/ecommerce-backend-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numq/ecommerce-backend/62cda50e1cc9a70f556687f2091dfcaebf1ce8e7/media/ecommerce-backend-overview.png -------------------------------------------------------------------------------- /order/.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=order 2 | MONGO_HOSTNAME=0.0.0.0 3 | MONGO_PORT=27017 4 | DATABASE_NAME=order 5 | COLLECTION_ITEMS=items 6 | SERVER_HOSTNAME=0.0.0.0 7 | SERVER_PORT=8004 -------------------------------------------------------------------------------- /order/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /build/ 3 | /.idea/ 4 | /src/generated/ -------------------------------------------------------------------------------- /order/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm install 5 | COPY tsconfig*.json . 6 | COPY .env . 7 | COPY ./src ./src 8 | RUN apk update && apk add protoc 9 | CMD ["rm", "-r", "src/generated"] 10 | CMD ["mkdir", "-p", "src/generated"] 11 | CMD ["protoc", "--plugin=./node_modules/.bin/protoc-gen-ts_proto", "--ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true", "--ts_proto_out=./src/generated", "-I=proto proto/*.proto"] 12 | RUN npm run build 13 | RUN npm prune --production 14 | 15 | FROM node:alpine 16 | ENV REDIS_HOSTNAME=redis 17 | COPY --from=0 app/.env . 18 | COPY --from=0 app/build . 19 | COPY --from=0 app/node_modules /node_modules 20 | CMD ["node", "index.js"] 21 | 22 | -------------------------------------------------------------------------------- /order/README.md: -------------------------------------------------------------------------------- 1 | # Order microservice 2 | 3 | ___ 4 | 5 | ## Technologies: 6 | 7 | - Node.js 8 | - TypeScript 9 | - Inversify 10 | - gRPC 11 | - TS-Proto 12 | - MongoDB 13 | 14 | ## Setup: 15 | 16 | ``` 17 | docker-compose up --build -d 18 | ``` -------------------------------------------------------------------------------- /order/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | order: 4 | build: . 5 | depends_on: 6 | - order_mongo 7 | environment: 8 | - MONGO_HOSTNAME=mongodb 9 | volumes: 10 | - ./:/data 11 | restart: on-failure 12 | ports: 13 | - '8004:8004' 14 | order_mongo: 15 | image: mongo:latest 16 | restart: on-failure 17 | ports: 18 | - '27017:27017' -------------------------------------------------------------------------------- /order/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "order", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "protoc": "npx protoc --plugin=protoc-gen-ts_proto=.\\node_modules\\.bin\\protoc-gen-ts_proto.cmd --ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true --ts_proto_out=./src/generated -I=../proto order.proto", 7 | "protoc-debug": "npx rimraf src/generated && npx mkdirp src/generated && npm run protoc", 8 | "build": "tsc -p .", 9 | "dev": "ts-node src/index.ts", 10 | "test": "jest" 11 | }, 12 | "dependencies": { 13 | "@grpc/grpc-js": "^1.7.3", 14 | "dotenv": "^16.0.3", 15 | "fp-ts": "^2.13.1", 16 | "inversify": "^6.0.1", 17 | "mongodb": "^4.11.0", 18 | "reflect-metadata": "^0.1.13" 19 | }, 20 | "devDependencies": { 21 | "@types/jest": "^29.2.2", 22 | "@types/node": "^18.11.9", 23 | "grpc-tools": "^1.11.3", 24 | "ts-jest": "^29.0.3", 25 | "ts-node": "^10.9.1", 26 | "ts-proto": "^1.132.1", 27 | "typescript": "^4.8.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /order/src/app/index.ts: -------------------------------------------------------------------------------- 1 | export const createApplication = (initialize: () => Promise, execute: () => Promise) => { 2 | Promise.all([initialize(), execute()]) 3 | .then(() => console.log("Successfully launched application.")) 4 | .catch(console.error); 5 | }; -------------------------------------------------------------------------------- /order/src/config/Config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from "inversify"; 2 | 3 | @injectable() 4 | export class Config { 5 | readonly MONGO_URL = `mongodb://${process.env.MONGO_HOSTNAME}:${process.env.MONGO_PORT}`; 6 | readonly SERVER_URL = `${process.env.SERVER_HOSTNAME}:${process.env.SERVER_PORT}`; 7 | readonly DATABASE_NAME = process.env.DATABASE_NAME; 8 | readonly COLLECTION_ITEMS = process.env.COLLECTION_ITEMS; 9 | } -------------------------------------------------------------------------------- /order/src/database/Database.ts: -------------------------------------------------------------------------------- 1 | import {Document, MongoClient} from "mongodb"; 2 | import {inject, injectable} from "inversify"; 3 | import {Types} from "../di/types"; 4 | import {Config} from "../config/Config"; 5 | 6 | @injectable() 7 | export class Database { 8 | client: MongoClient | null = null; 9 | 10 | constructor(@inject(Types.app.config) private readonly config: Config) { 11 | } 12 | 13 | open = async () => new MongoClient(this.config.MONGO_URL).connect().then(client => { 14 | this.client = client; 15 | console.log(`Connected to database: ${this.config.MONGO_URL}`); 16 | }).catch(console.error); 17 | 18 | close = () => this.client?.close().then(() => { 19 | console.log(`Disconnected from database: ${this.config.MONGO_URL}`); 20 | this.client = null; 21 | }).catch(console.error); 22 | 23 | collection = (name: string) => this.client?.db(this.config.DATABASE_NAME).collection(name); 24 | } -------------------------------------------------------------------------------- /order/src/di/types.ts: -------------------------------------------------------------------------------- 1 | export namespace Types { 2 | export const app = { 3 | config: Symbol.for("config"), 4 | database: Symbol.for("database"), 5 | server: Symbol.for("server") 6 | }; 7 | export const order = { 8 | collection: Symbol.for("orderCollection"), 9 | repository: Symbol.for("orderRepository"), 10 | service: Symbol.for("orderService"), 11 | createOrder: Symbol.for("createOrder"), 12 | getOrderById: Symbol.for("getOrderById"), 13 | getCustomerOrders: Symbol.for("getCustomerOrders"), 14 | updateOrder: Symbol.for("updateOrder"), 15 | deleteOrder: Symbol.for("deleteOrder") 16 | }; 17 | } -------------------------------------------------------------------------------- /order/src/index.ts: -------------------------------------------------------------------------------- 1 | import {Module} from "./di/module"; 2 | import {Types} from "./di/types"; 3 | import {Server} from "./server/Server"; 4 | import * as dotenv from "dotenv"; 5 | import {Database} from "./database/Database"; 6 | import {createApplication} from "./app"; 7 | import {OrderServiceServer, OrderServiceService} from "./generated/order"; 8 | 9 | const initialize = async () => { 10 | dotenv.config(); 11 | Module.initModules(); 12 | }; 13 | 14 | const execute = async () => { 15 | await Module.container.get(Types.app.database).open(); 16 | await Module.container.get(Types.app.server).launch(server => { 17 | server.addService(OrderServiceService, Module.container.get(Types.order.service)); 18 | }); 19 | }; 20 | 21 | createApplication(initialize, execute); -------------------------------------------------------------------------------- /order/src/interactor/UseCase.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | 3 | export abstract class UseCase { 4 | abstract execute(arg: T): TaskEither 5 | } -------------------------------------------------------------------------------- /order/src/order/CreateOrder.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Types} from "../di/types"; 4 | import {OrderRepository} from "./OrderRepository"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {Order} from "./Order"; 7 | import {taskEither as TE} from "fp-ts"; 8 | import {pipe} from "fp-ts/function"; 9 | import {OrderError} from "./OrderError"; 10 | 11 | @injectable() 12 | export class CreateOrder extends UseCase { 13 | constructor(@inject(Types.order.repository) private readonly repository: OrderRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: Order): TaskEither => pipe( 18 | this.repository.addOrder(arg), 19 | TE.chain(TE.fromNullable(OrderError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /order/src/order/DeleteOrder.ts: -------------------------------------------------------------------------------- 1 | import {UseCase} from "../interactor/UseCase"; 2 | import {inject, injectable} from "inversify"; 3 | import {Types} from "../di/types"; 4 | import {OrderRepository} from "./OrderRepository"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {taskEither as TE} from "fp-ts"; 7 | import {OrderError} from "./OrderError"; 8 | import {pipe} from "fp-ts/function"; 9 | 10 | @injectable() 11 | export class DeleteOrder extends UseCase { 12 | constructor(@inject(Types.order.repository) private readonly repository: OrderRepository) { 13 | super(); 14 | } 15 | 16 | execute = (arg: string): TaskEither => pipe( 17 | this.repository.removeOrder(arg), 18 | TE.chain(TE.fromNullable(OrderError.NotFound)) 19 | ); 20 | } -------------------------------------------------------------------------------- /order/src/order/GetCustomerOrders.ts: -------------------------------------------------------------------------------- 1 | import {UseCase} from "../interactor/UseCase"; 2 | import {Order} from "./Order"; 3 | import {inject, injectable} from "inversify"; 4 | import {Types} from "../di/types"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {OrderRepository} from "./OrderRepository"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {OrderError} from "./OrderError"; 10 | 11 | @injectable() 12 | export class GetCustomerOrders extends UseCase { 13 | constructor(@inject(Types.order.repository) private readonly repository: OrderRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: string): TaskEither => pipe( 18 | this.repository.getOrdersByCustomerId(arg), 19 | TE.chain(TE.fromNullable(OrderError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /order/src/order/GetOrderById.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Order} from "./Order"; 4 | import {Types} from "../di/types"; 5 | import {OrderRepository} from "./OrderRepository"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {OrderError} from "./OrderError"; 10 | 11 | @injectable() 12 | export class GetOrderById extends UseCase { 13 | constructor(@inject(Types.order.repository) private readonly repository: OrderRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: string): TaskEither => pipe( 18 | this.repository.getOrderById(arg), 19 | TE.chain(TE.fromNullable(OrderError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /order/src/order/Order.ts: -------------------------------------------------------------------------------- 1 | import {OrderStatus} from "./OrderStatus"; 2 | import {OrderedItem} from "./OrderedItem"; 3 | 4 | export type Order = { 5 | id: string; 6 | customerId: string; 7 | items: OrderedItem[]; 8 | discount: string; 9 | price: string; 10 | status: OrderStatus; 11 | creationDate: number; 12 | deliveryDate: number; 13 | }; -------------------------------------------------------------------------------- /order/src/order/OrderError.ts: -------------------------------------------------------------------------------- 1 | export namespace OrderError { 2 | export const NotFound = new Error("Catalog not found"); 3 | } -------------------------------------------------------------------------------- /order/src/order/OrderMapper.ts: -------------------------------------------------------------------------------- 1 | import {Order as OrderMessage} from "../generated/order"; 2 | import {Order} from "./Order"; 3 | import {OrderedItemMapper} from "./OrderedItemMapper"; 4 | 5 | export namespace OrderMapper { 6 | export const entityToMessage = (entity: Order): OrderMessage => ({ 7 | id: entity.id, 8 | customerId: entity.customerId, 9 | items: entity.items.map(OrderedItemMapper.entityToMessage), 10 | discount: entity.discount, 11 | price: entity.price, 12 | status: entity.status.valueOf(), 13 | creationDate: entity.creationDate, 14 | deliveryDate: entity.deliveryDate 15 | }); 16 | export const messageToEntity = (message: OrderMessage): Order => ({ 17 | id: message.id, 18 | customerId: message.customerId, 19 | items: message.items.map(OrderedItemMapper.messageToEntity), 20 | discount: message.discount, 21 | price: message.price, 22 | status: message.status.valueOf(), 23 | creationDate: message.creationDate, 24 | deliveryDate: message.deliveryDate 25 | }); 26 | } -------------------------------------------------------------------------------- /order/src/order/OrderStatus.ts: -------------------------------------------------------------------------------- 1 | export enum OrderStatus { 2 | CREATED, PENDING_PAYMENT, PROCESSING, SHIPPING, COMPLETED, CANCELED, REFUNDED 3 | } -------------------------------------------------------------------------------- /order/src/order/OrderedItem.ts: -------------------------------------------------------------------------------- 1 | export type OrderedItem = { 2 | id: string; 3 | sku: string; 4 | name: string; 5 | quantity: string; 6 | discount: string; 7 | price: string; 8 | } -------------------------------------------------------------------------------- /order/src/order/OrderedItemMapper.ts: -------------------------------------------------------------------------------- 1 | import {OrderedItem as OrderedItemMessage} from "../generated/order"; 2 | import {OrderedItem} from "./OrderedItem"; 3 | 4 | export namespace OrderedItemMapper { 5 | export const entityToMessage = (entity: OrderedItem): OrderedItemMessage => ({ 6 | id: entity.id, 7 | sku: entity.sku, 8 | name: entity.name, 9 | quantity: entity.quantity, 10 | discount: entity.discount, 11 | price: entity.price 12 | }); 13 | export const messageToEntity = (message: OrderedItemMessage): OrderedItem => ({ 14 | id: message.id, 15 | sku: message.sku, 16 | name: message.name, 17 | quantity: message.quantity, 18 | discount: message.discount, 19 | price: message.price 20 | }); 21 | } -------------------------------------------------------------------------------- /order/src/order/UpdateOrder.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Types} from "../di/types"; 4 | import {OrderRepository} from "./OrderRepository"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {Order} from "./Order"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {OrderError} from "./OrderError"; 10 | 11 | @injectable() 12 | export class UpdateOrder extends UseCase { 13 | constructor(@inject(Types.order.repository) private readonly repository: OrderRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: Order): TaskEither => pipe( 18 | this.repository.updateOrder(arg), 19 | TE.chain(TE.fromNullable(OrderError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /order/src/response/ResponseError.ts: -------------------------------------------------------------------------------- 1 | export namespace ResponseError { 2 | export const map = new Error("Unable to map value"); 3 | } -------------------------------------------------------------------------------- /order/src/response/index.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | import {flow} from "fp-ts/function"; 3 | import {either as E} from "fp-ts"; 4 | import {ResponseError} from "./ResponseError"; 5 | 6 | export const response = (input: TaskEither, callback: (e: Error | null, r: R | null) => void, map: (t: T) => R): void => { 7 | input().then(flow(E.chain((t: T) => E.tryCatch(() => map(t), () => ResponseError.map)), E.fold((e: Error) => callback(e, null), (r: R) => callback(null, r)))).catch(e => callback(e, null)); 8 | } -------------------------------------------------------------------------------- /order/src/server/Server.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {Types} from "../di/types"; 3 | import {Config} from "../config/Config"; 4 | import {Server as GrpcServer, ServerCredentials} from "@grpc/grpc-js"; 5 | 6 | @injectable() 7 | export class Server { 8 | constructor(@inject(Types.app.config) private readonly config: Config) { 9 | } 10 | 11 | async launch(bind: (server: GrpcServer) => void) { 12 | const server: GrpcServer = new GrpcServer(); 13 | bind(server); 14 | server.bindAsync(this.config.SERVER_URL, ServerCredentials.createInsecure(), (error, port) => { 15 | if (error) console.error(error); 16 | server.start(); 17 | console.log(`Server running on port: ${port}`); 18 | }); 19 | }; 20 | } -------------------------------------------------------------------------------- /profile/.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=profile 2 | MONGO_HOSTNAME=0.0.0.0 3 | MONGO_PORT=27017 4 | DATABASE_NAME=profile 5 | COLLECTION_ITEMS=items 6 | SERVER_HOSTNAME=0.0.0.0 7 | SERVER_PORT=8008 -------------------------------------------------------------------------------- /profile/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /build/ 3 | /.idea/ 4 | /src/generated/ -------------------------------------------------------------------------------- /profile/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm install 5 | COPY tsconfig*.json . 6 | COPY .env . 7 | COPY ./src ./src 8 | RUN apk update && apk add protoc 9 | CMD ["rm", "-r", "src/generated"] 10 | CMD ["mkdir", "-p", "src/generated"] 11 | CMD ["protoc", "--plugin=./node_modules/.bin/protoc-gen-ts_proto", "--ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true", "--ts_proto_out=./src/generated", "-I=proto proto/*.proto"] 12 | RUN npm run build 13 | RUN npm prune --production 14 | 15 | FROM node:alpine 16 | ENV REDIS_HOSTNAME=redis 17 | COPY --from=0 app/.env . 18 | COPY --from=0 app/build . 19 | COPY --from=0 app/node_modules /node_modules 20 | CMD ["node", "index.js"] -------------------------------------------------------------------------------- /profile/README.md: -------------------------------------------------------------------------------- 1 | # Profile microservice 2 | 3 | ___ 4 | 5 | ## Technologies: 6 | 7 | - Node.js 8 | - TypeScript 9 | - Inversify 10 | - gRPC 11 | - TS-Proto 12 | - MongoDB 13 | 14 | ## Setup: 15 | 16 | ``` 17 | docker-compose up --build -d 18 | ``` -------------------------------------------------------------------------------- /profile/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | profile: 4 | build: . 5 | depends_on: 6 | - profile_mongo 7 | environment: 8 | - MONGO_HOSTNAME=mongodb 9 | volumes: 10 | - ./:/data 11 | restart: on-failure 12 | ports: 13 | - '8008:8008' 14 | profile_mongo: 15 | image: mongo:latest 16 | restart: on-failure 17 | ports: 18 | - '27017:27017' -------------------------------------------------------------------------------- /profile/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "profile", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "protoc": "npx protoc --plugin=protoc-gen-ts_proto=.\\node_modules\\.bin\\protoc-gen-ts_proto.cmd --ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true --ts_proto_out=./src/generated -I=../proto profile.proto", 7 | "protoc-debug": "npx rimraf src/generated && npx mkdirp src/generated && npm run protoc", 8 | "build": "tsc -p .", 9 | "dev": "ts-node src/index.ts", 10 | "test": "jest" 11 | }, 12 | "dependencies": { 13 | "@grpc/grpc-js": "^1.7.3", 14 | "dotenv": "^16.0.3", 15 | "fp-ts": "^2.13.1", 16 | "inversify": "^6.0.1", 17 | "mongodb": "^4.11.0", 18 | "reflect-metadata": "^0.1.13" 19 | }, 20 | "devDependencies": { 21 | "@types/jest": "^29.2.2", 22 | "@types/node": "^18.11.9", 23 | "grpc-tools": "^1.11.3", 24 | "ts-jest": "^29.0.3", 25 | "ts-node": "^10.9.1", 26 | "ts-proto": "^1.132.1", 27 | "typescript": "^4.8.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /profile/src/app/index.ts: -------------------------------------------------------------------------------- 1 | export const createApplication = (initialize: () => Promise, execute: () => Promise) => { 2 | Promise.all([initialize(), execute()]) 3 | .then(() => console.log("Successfully launched application.")) 4 | .catch(console.error); 5 | }; -------------------------------------------------------------------------------- /profile/src/config/Config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from "inversify"; 2 | 3 | @injectable() 4 | export class Config { 5 | readonly MONGO_URL = `mongodb://${process.env.MONGO_HOSTNAME}:${process.env.MONGO_PORT}`; 6 | readonly SERVER_URL = `${process.env.SERVER_HOSTNAME}:${process.env.SERVER_PORT}`; 7 | readonly DATABASE_NAME = process.env.DATABASE_NAME; 8 | readonly COLLECTION_ITEMS = process.env.COLLECTION_ITEMS; 9 | } -------------------------------------------------------------------------------- /profile/src/database/Database.ts: -------------------------------------------------------------------------------- 1 | import {Document, MongoClient} from "mongodb"; 2 | import {inject, injectable} from "inversify"; 3 | import {Types} from "../di/types"; 4 | import {Config} from "../config/Config"; 5 | 6 | @injectable() 7 | export class Database { 8 | client: MongoClient | null = null; 9 | 10 | constructor(@inject(Types.app.config) private readonly config: Config) { 11 | } 12 | 13 | open = async () => new MongoClient(this.config.MONGO_URL).connect().then(client => { 14 | this.client = client; 15 | console.log(`Connected to database: ${this.config.MONGO_URL}`); 16 | }).catch(console.error); 17 | 18 | close = () => this.client?.close().then(() => { 19 | console.log(`Disconnected from database: ${this.config.MONGO_URL}`); 20 | this.client = null; 21 | }).catch(console.error); 22 | 23 | collection = (name: string) => this.client?.db(this.config.DATABASE_NAME).collection(name); 24 | } -------------------------------------------------------------------------------- /profile/src/database/DatabaseError.ts: -------------------------------------------------------------------------------- 1 | export namespace DatabaseError { 2 | export const id = new Error("Unable to create object id"); 3 | export const insert = new Error("Unable to insert document"); 4 | export const findOne = new Error("Unable to find one document"); 5 | export const find = new Error("Unable to find documents"); 6 | export const update = new Error("Unable to update document"); 7 | export const deleteOne = new Error("Unable to delete one document"); 8 | export const deleteMany = new Error("Unable to delete many documents"); 9 | } -------------------------------------------------------------------------------- /profile/src/di/types.ts: -------------------------------------------------------------------------------- 1 | export namespace Types { 2 | export const app = { 3 | config: Symbol.for("config"), 4 | database: Symbol.for("database"), 5 | server: Symbol.for("server") 6 | }; 7 | export const profile = { 8 | collection: Symbol.for("profileCollection"), 9 | repository: Symbol.for("profileRepository"), 10 | service: Symbol.for("profileService"), 11 | createProfile: Symbol.for("createProfile"), 12 | getProfileById: Symbol.for("getProfileById"), 13 | updateProfile: Symbol.for("updateProfile"), 14 | removeProfile: Symbol.for("removeProfile") 15 | }; 16 | } -------------------------------------------------------------------------------- /profile/src/index.ts: -------------------------------------------------------------------------------- 1 | import {Module} from "./di/module"; 2 | import {Types} from "./di/types"; 3 | import {Server} from "./server/Server"; 4 | import * as dotenv from "dotenv"; 5 | import {Database} from "./database/Database"; 6 | import {createApplication} from "./app"; 7 | import {ProfileServiceServer, ProfileServiceService} from "./generated/profile"; 8 | 9 | const initialize = async () => { 10 | dotenv.config(); 11 | Module.initModules(); 12 | }; 13 | 14 | const execute = async () => { 15 | await Module.container.get(Types.app.database).open(); 16 | await Module.container.get(Types.app.server).launch(server => { 17 | server.addService(ProfileServiceService, Module.container.get(Types.profile.service)); 18 | }); 19 | }; 20 | 21 | createApplication(initialize, execute); -------------------------------------------------------------------------------- /profile/src/interactor/UseCase.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | 3 | export abstract class UseCase { 4 | abstract execute(arg: T): TaskEither 5 | } -------------------------------------------------------------------------------- /profile/src/profile/CreateProfile.ts: -------------------------------------------------------------------------------- 1 | import {UseCase} from "../interactor/UseCase"; 2 | import {Profile} from "./Profile"; 3 | import {inject, injectable} from "inversify"; 4 | import {Types} from "../di/types"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {pipe} from "fp-ts/function"; 7 | import {ProfileRepository} from "./ProfileRepository"; 8 | import {ProfileError} from "./ProfileError"; 9 | import {taskEither as TE} from "fp-ts"; 10 | 11 | @injectable() 12 | export class CreateProfile extends UseCase { 13 | constructor(@inject(Types.profile.repository) private readonly repository: ProfileRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: Profile): TaskEither => pipe( 18 | this.repository.createProfile(arg), 19 | TE.chain(TE.fromNullable(ProfileError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /profile/src/profile/GetProfileById.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Profile} from "./Profile"; 4 | import {Types} from "../di/types"; 5 | import {ProfileRepository} from "./ProfileRepository"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {ProfileError} from "./ProfileError"; 10 | 11 | @injectable() 12 | export class GetProfileById extends UseCase { 13 | constructor(@inject(Types.profile.repository) private readonly repository: ProfileRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: string): TaskEither => pipe( 18 | this.repository.getProfileById(arg), 19 | TE.chain(TE.fromNullable(ProfileError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /profile/src/profile/Profile.ts: -------------------------------------------------------------------------------- 1 | export type Profile = { 2 | id: string; 3 | name: string; 4 | addresses: string[]; 5 | }; -------------------------------------------------------------------------------- /profile/src/profile/ProfileError.ts: -------------------------------------------------------------------------------- 1 | export namespace ProfileError { 2 | export const NotFound = new Error("Profile not found"); 3 | } -------------------------------------------------------------------------------- /profile/src/profile/ProfileMapper.ts: -------------------------------------------------------------------------------- 1 | import {Profile as ProfileMessage} from "../generated/profile"; 2 | import {Profile} from "./Profile"; 3 | 4 | export namespace ProfileMapper { 5 | export const entityToMessage = (entity: Profile): ProfileMessage => ({ 6 | id: entity.id, 7 | name: entity.name, 8 | addresses: entity.addresses 9 | }); 10 | export const messageToEntity = (message: ProfileMessage): Profile => ({ 11 | id: message.id, 12 | name: message.name, 13 | addresses: message.addresses 14 | }); 15 | } -------------------------------------------------------------------------------- /profile/src/profile/RemoveProfile.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Types} from "../di/types"; 4 | import {ProfileRepository} from "./ProfileRepository"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {pipe} from "fp-ts/function"; 7 | import {taskEither as TE} from "fp-ts"; 8 | import {ProfileError} from "./ProfileError"; 9 | 10 | @injectable() 11 | export class RemoveProfile extends UseCase { 12 | constructor(@inject(Types.profile.repository) private readonly repository: ProfileRepository) { 13 | super(); 14 | } 15 | 16 | execute = (arg: string): TaskEither => pipe( 17 | this.repository.removeProfile(arg), 18 | TE.chain(TE.fromNullable(ProfileError.NotFound)) 19 | ); 20 | } -------------------------------------------------------------------------------- /profile/src/profile/UpdateProfile.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Profile} from "./Profile"; 4 | import {Types} from "../di/types"; 5 | import {ProfileRepository} from "./ProfileRepository"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {ProfileError} from "./ProfileError"; 10 | 11 | @injectable() 12 | export class UpdateProfile extends UseCase { 13 | constructor(@inject(Types.profile.repository) private readonly repository: ProfileRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: Profile): TaskEither => pipe( 18 | this.repository.updateProfile(arg), 19 | TE.chain(TE.fromNullable(ProfileError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /profile/src/response/ResponseError.ts: -------------------------------------------------------------------------------- 1 | export namespace ResponseError { 2 | export const map = new Error("Unable to map value"); 3 | } -------------------------------------------------------------------------------- /profile/src/response/index.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | import {flow} from "fp-ts/function"; 3 | import {either as E} from "fp-ts"; 4 | import {ResponseError} from "./ResponseError"; 5 | 6 | export const response = (input: TaskEither, callback: (e: Error | null, r: R | null) => void, map: (t: T) => R): void => { 7 | input().then(flow(E.chain((t: T) => E.tryCatch(() => map(t), () => ResponseError.map)), E.fold((e: Error) => callback(e, null), (r: R) => callback(null, r)))).catch(e => callback(e, null)); 8 | } -------------------------------------------------------------------------------- /profile/src/server/Server.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {Types} from "../di/types"; 3 | import {Config} from "../config/Config"; 4 | import {Server as GrpcServer, ServerCredentials} from "@grpc/grpc-js"; 5 | 6 | @injectable() 7 | export class Server { 8 | constructor(@inject(Types.app.config) private readonly config: Config) { 9 | } 10 | 11 | async launch(bind: (server: GrpcServer) => void) { 12 | const server: GrpcServer = new GrpcServer(); 13 | bind(server); 14 | server.bindAsync(this.config.SERVER_URL, ServerCredentials.createInsecure(), (error, port) => { 15 | if (error) console.error(error); 16 | server.start(); 17 | console.log(`Server running on port: ${port}`); 18 | }); 19 | }; 20 | } -------------------------------------------------------------------------------- /promo/.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=promo 2 | REDIS_HOSTNAME=0.0.0.0 3 | REDIS_PORT=6379 4 | REDIS_NAME=promo 5 | SERVER_HOSTNAME=0.0.0.0 6 | SERVER_PORT=8009 -------------------------------------------------------------------------------- /promo/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /build/ 3 | /.idea/ 4 | /src/generated/ -------------------------------------------------------------------------------- /promo/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | WORKDIR /app 3 | COPY package*.json . 4 | RUN npm install 5 | COPY tsconfig*.json . 6 | COPY .env . 7 | COPY ./src ./src 8 | RUN apk update && apk add protoc 9 | CMD ["rm", "-r", "src/generated"] 10 | CMD ["mkdir", "-p", "src/generated"] 11 | CMD ["protoc", "--plugin=./node_modules/.bin/protoc-gen-ts_proto", "--ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true", "--ts_proto_out=./src/generated", "-I=proto proto/*.proto"] 12 | RUN npm run build 13 | RUN npm prune --production 14 | 15 | FROM node:alpine 16 | COPY --from=0 app/.env . 17 | COPY --from=0 app/build . 18 | COPY --from=0 app/node_modules /node_modules 19 | CMD ["node", "index.js"] -------------------------------------------------------------------------------- /promo/README.md: -------------------------------------------------------------------------------- 1 | # Promo microservice 2 | 3 | ___ 4 | 5 | ## Technologies: 6 | 7 | - Node.js 8 | - TypeScript 9 | - Inversify 10 | - gRPC 11 | - TS-Proto 12 | - Redis 13 | 14 | ## Setup: 15 | 16 | ``` 17 | docker-compose up --build -d 18 | ``` -------------------------------------------------------------------------------- /promo/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | promo: 4 | build: . 5 | depends_on: 6 | - promo_redis 7 | environment: 8 | - REDIS_HOSTNAME=redis 9 | volumes: 10 | - ./:/data 11 | restart: on-failure 12 | ports: 13 | - '8009:8009' 14 | promo_redis: 15 | image: redis:latest 16 | restart: on-failure 17 | ports: 18 | - '6379:6379' -------------------------------------------------------------------------------- /promo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "promo", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "protoc": "npx protoc --plugin=protoc-gen-ts_proto=.\\node_modules\\.bin\\protoc-gen-ts_proto.cmd --ts_proto_opt=outputServices=grpc-js,env=node,useOptionals=messages,exportCommonSymbols=false,esModuleInterop=true --ts_proto_out=./src/generated -I=../proto promo.proto", 7 | "protoc-debug": "npx rimraf src/generated && npx mkdirp src/generated && npm run protoc", 8 | "build": "tsc -p .", 9 | "dev": "ts-node src/index.ts", 10 | "test": "jest" 11 | }, 12 | "dependencies": { 13 | "@grpc/grpc-js": "^1.7.3", 14 | "dotenv": "^16.0.3", 15 | "fp-ts": "^2.13.1", 16 | "inversify": "^6.0.1", 17 | "redis": "^4.4.0", 18 | "reflect-metadata": "^0.1.13" 19 | }, 20 | "devDependencies": { 21 | "@types/jest": "^29.2.2", 22 | "@types/node": "^18.11.9", 23 | "grpc-tools": "^1.11.3", 24 | "ts-jest": "^29.0.3", 25 | "ts-node": "^10.9.1", 26 | "ts-proto": "^1.132.1", 27 | "typescript": "^4.8.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /promo/src/application/index.ts: -------------------------------------------------------------------------------- 1 | export const createApplication = (initialize: () => Promise, execute: () => Promise) => { 2 | Promise.all([initialize(), execute()]) 3 | .then(() => console.log("Successfully launched application.")) 4 | .catch(console.error); 5 | }; -------------------------------------------------------------------------------- /promo/src/config/Config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from "inversify"; 2 | 3 | @injectable() 4 | export class Config { 5 | readonly REDIS_URL = `redis://${process.env.REDIS_HOSTNAME}:${process.env.REDIS_PORT}`; 6 | readonly REDIS_NAME = process.env.REDIS_NAME; 7 | readonly SERVER_URL = `${process.env.SERVER_HOSTNAME}:${process.env.SERVER_PORT}`; 8 | } -------------------------------------------------------------------------------- /promo/src/di/module.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import {Container, ContainerModule} from "inversify"; 3 | import {Config} from "../config/Config"; 4 | import {Store} from "../store/Store"; 5 | import {Types} from "./types"; 6 | import {PromoServiceServer} from "../promo/PromoServiceServer"; 7 | import {PromoRepository, PromoRepositoryImpl} from "../promo/PromoRepository"; 8 | import {Server} from "../server/Server"; 9 | import {InsertPromo} from "../promo/InsertPromo"; 10 | import {GetPromo} from "../promo/GetPromo"; 11 | import {RemovePromo} from "../promo/RemovePromo"; 12 | import {PromoService} from "../promo/PromoService"; 13 | 14 | export namespace Module { 15 | 16 | export const container = new Container({defaultScope: "Singleton", skipBaseClassChecks: true}); 17 | export const initModules = () => [app, promo].forEach(m => container.load(m)); 18 | 19 | const app = new ContainerModule(bind => { 20 | bind(Types.app.config).to(Config).inSingletonScope(); 21 | bind(Types.app.store).to(Store).inSingletonScope(); 22 | bind(Types.app.server).to(Server).inSingletonScope(); 23 | }); 24 | 25 | const promo = new ContainerModule(bind => { 26 | bind(Types.promo.service).to(PromoService).inSingletonScope(); 27 | bind(Types.promo.repository).to(PromoRepositoryImpl).inSingletonScope(); 28 | bind(Types.promo.insertPromo).to(InsertPromo).inTransientScope(); 29 | bind(Types.promo.getPromo).to(GetPromo).inTransientScope(); 30 | bind(Types.promo.removePromo).to(RemovePromo).inTransientScope(); 31 | }); 32 | } -------------------------------------------------------------------------------- /promo/src/di/types.ts: -------------------------------------------------------------------------------- 1 | export namespace Types { 2 | export const app = { 3 | config: Symbol.for("config"), 4 | store: Symbol.for("store"), 5 | server: Symbol.for("server") 6 | }; 7 | export const promo = { 8 | repository: Symbol.for("promoRepository"), 9 | service: Symbol.for("promoService"), 10 | insertPromo: Symbol.for("insertPromo"), 11 | getPromo: Symbol.for("getPromo"), 12 | removePromo: Symbol.for("removePromo") 13 | }; 14 | } -------------------------------------------------------------------------------- /promo/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv"; 2 | import {Store} from "./store/Store"; 3 | import {Server} from "./server/Server"; 4 | import {Types} from "./di/types"; 5 | import {Module} from "./di/module"; 6 | import {PromoServiceServer, PromoServiceService} from "./generated/promo"; 7 | import {createApplication} from "./application"; 8 | 9 | const initialize = async () => { 10 | dotenv.config(); 11 | Module.initModules(); 12 | }; 13 | 14 | const execute = async () => { 15 | await Module.container.get(Types.app.store).open(); 16 | await Module.container.get(Types.app.server).launch(server => { 17 | server.addService(PromoServiceService, Module.container.get(Types.promo.service)); 18 | }); 19 | }; 20 | 21 | createApplication(initialize, execute); -------------------------------------------------------------------------------- /promo/src/interactor/UseCase.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | 3 | export abstract class UseCase { 4 | abstract execute(arg: T): TaskEither 5 | } -------------------------------------------------------------------------------- /promo/src/promo/GetPromo.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Promo} from "./Promo"; 4 | import {Types} from "../di/types"; 5 | import {PromoRepository} from "./PromoRepository"; 6 | import {TaskEither} from "fp-ts/TaskEither"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {PromoError} from "./PromoError"; 10 | 11 | @injectable() 12 | export class GetPromo extends UseCase { 13 | constructor(@inject(Types.promo.repository) private readonly promoRepository: PromoRepository) { 14 | super(); 15 | } 16 | 17 | execute = (arg: string): TaskEither => pipe( 18 | this.promoRepository.getPromo(arg), 19 | TE.chain(TE.fromNullable(PromoError.NotFound)) 20 | ); 21 | } -------------------------------------------------------------------------------- /promo/src/promo/InsertPromo.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {Promo} from "./Promo"; 3 | import {Types} from "../di/types"; 4 | import {TaskEither} from "fp-ts/TaskEither"; 5 | import {UseCase} from "../interactor/UseCase"; 6 | import {PromoRepository} from "./PromoRepository"; 7 | import {pipe} from "fp-ts/function"; 8 | import {taskEither as TE} from "fp-ts"; 9 | import {PromoError} from "./PromoError"; 10 | 11 | 12 | @injectable() 13 | export class InsertPromo extends UseCase<[string, boolean, number, string[], string[], boolean, number], Promo> { 14 | constructor(@inject(Types.promo.repository) private readonly promoRepository: PromoRepository) { 15 | super(); 16 | } 17 | 18 | execute = (arg: [string, boolean, number, string[], string[], boolean, number]): TaskEither => pipe( 19 | TE.Do, 20 | TE.map(() => arg), 21 | TE.bind("promo", ([value, reusable, requiredAmount, categoryIds, productIds, freeShipping, expirationTime]) => TE.of({ 22 | value: value, 23 | reusable: reusable ? reusable : false, 24 | requiredAmount: requiredAmount ? requiredAmount : 0, 25 | categoryIds: categoryIds ? categoryIds : [], 26 | productIds: productIds ? productIds : [], 27 | freeShipping: freeShipping ? freeShipping : false, 28 | expirationTime: expirationTime 29 | })), 30 | TE.chain(({promo}) => this.promoRepository.insertPromo(promo)), 31 | TE.chain(TE.fromNullable(PromoError.NotFound)) 32 | ); 33 | } -------------------------------------------------------------------------------- /promo/src/promo/Promo.ts: -------------------------------------------------------------------------------- 1 | export type Promo = { 2 | value: string; 3 | reusable: boolean; 4 | requiredAmount: number; 5 | productIds: string[]; 6 | categoryIds: string[]; 7 | freeShipping: boolean; 8 | expirationTime: number; 9 | }; -------------------------------------------------------------------------------- /promo/src/promo/PromoError.ts: -------------------------------------------------------------------------------- 1 | export namespace PromoError { 2 | export const InsertionFailed = new Error("Failed to insert promo"); 3 | export const NotFound = new Error("Promo not found"); 4 | export const RemovalFailed = new Error("Failed to remove promo"); 5 | } -------------------------------------------------------------------------------- /promo/src/promo/PromoMapper.ts: -------------------------------------------------------------------------------- 1 | import {Promo as PromoMessage} from "../generated/promo"; 2 | import {Promo} from "./Promo"; 3 | 4 | export namespace PromoMapper { 5 | export const entityToMessage = (entity: Promo): PromoMessage => ({ 6 | value: entity.value, 7 | reusable: entity.reusable, 8 | requiredAmount: entity.requiredAmount, 9 | categoryIds: entity.categoryIds, 10 | productIds: entity.productIds, 11 | freeShipping: entity.freeShipping, 12 | expirationTime: entity.expirationTime 13 | }); 14 | export const messageToEntity = (message: PromoMessage): Promo => ({ 15 | value: message.value, 16 | reusable: message.reusable, 17 | requiredAmount: message.requiredAmount, 18 | categoryIds: message.categoryIds, 19 | productIds: message.productIds, 20 | freeShipping: message.freeShipping, 21 | expirationTime: message.expirationTime 22 | }); 23 | } -------------------------------------------------------------------------------- /promo/src/promo/PromoServiceServer.ts: -------------------------------------------------------------------------------- 1 | export class PromoServiceServer { 2 | 3 | } -------------------------------------------------------------------------------- /promo/src/promo/RemovePromo.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from "inversify"; 2 | import {UseCase} from "../interactor/UseCase"; 3 | import {Types} from "../di/types"; 4 | import {PromoRepository} from "./PromoRepository"; 5 | import {TaskEither} from "fp-ts/TaskEither"; 6 | import {pipe} from "fp-ts/function"; 7 | import {taskEither as TE} from "fp-ts"; 8 | import {PromoError} from "./PromoError"; 9 | 10 | @injectable() 11 | export class RemovePromo extends UseCase { 12 | constructor(@inject(Types.promo.repository) private readonly promoRepository: PromoRepository) { 13 | super(); 14 | } 15 | 16 | execute = (arg: string): TaskEither => pipe( 17 | this.promoRepository.removePromo(arg), 18 | TE.chain(TE.fromNullable(PromoError.NotFound)) 19 | ); 20 | } -------------------------------------------------------------------------------- /promo/src/response/ResponseError.ts: -------------------------------------------------------------------------------- 1 | export namespace ResponseError { 2 | export const map = new Error("Unable to map value"); 3 | } -------------------------------------------------------------------------------- /promo/src/response/index.ts: -------------------------------------------------------------------------------- 1 | import {TaskEither} from "fp-ts/TaskEither"; 2 | import {Either} from "fp-ts/Either"; 3 | import {pipe} from "fp-ts/function"; 4 | import {either as E} from "fp-ts"; 5 | import {ResponseError} from "./ResponseError"; 6 | 7 | 8 | export const response = (input: TaskEither, callback: (e: Error | null, r: R | null) => void, map: (t: T) => R): void => { 9 | input().then((either: Either) => { 10 | pipe(either, 11 | E.chain((value: T) => 12 | E.tryCatch(() => map(value), e => e instanceof Error ? e : ResponseError.map)), 13 | E.fold((e: Error) => { 14 | console.error(e); 15 | callback(e, null); 16 | }, (value: R) => { 17 | callback(null, value); 18 | } 19 | ) 20 | ); 21 | }).catch((e: Error) => { 22 | console.error(e); 23 | callback(e, null); 24 | }); 25 | }; -------------------------------------------------------------------------------- /promo/src/server/Server.ts: -------------------------------------------------------------------------------- 1 | import {Config} from "../config/Config"; 2 | import {Server as GrpcServer, ServerCredentials} from "@grpc/grpc-js"; 3 | import {inject, injectable} from "inversify"; 4 | import {Types} from "../di/types"; 5 | 6 | @injectable() 7 | export class Server { 8 | constructor(@inject(Types.app.config) private readonly config: Config) { 9 | } 10 | 11 | async launch(bind: (server: GrpcServer) => void) { 12 | const server: GrpcServer = new GrpcServer(); 13 | bind(server); 14 | server.bindAsync(this.config.SERVER_URL, ServerCredentials.createInsecure(), (error, port) => { 15 | if (error) console.error(error); 16 | server.start(); 17 | console.log(`Server running on port: ${port}`); 18 | }); 19 | }; 20 | } -------------------------------------------------------------------------------- /promo/src/store/Store.ts: -------------------------------------------------------------------------------- 1 | import {createClient, RedisClientType} from "redis"; 2 | import {Types} from "../di/types"; 3 | import {inject, injectable} from "inversify"; 4 | import {Config} from "../config/Config"; 5 | 6 | @injectable() 7 | export class Store { 8 | client: RedisClientType | null = null; 9 | 10 | constructor(@inject(Types.app.config) private readonly config: Config) { 11 | } 12 | 13 | open = async () => new Promise((resolve, reject) => { 14 | try { 15 | resolve(createClient({ 16 | name: this.config.REDIS_NAME, 17 | url: this.config.REDIS_URL 18 | })); 19 | } catch (e) { 20 | reject(e); 21 | } 22 | }).then(client => { 23 | client.connect().then(() => { 24 | this.client = client; 25 | console.log(`Connected to store: ${this.config.REDIS_URL}`); 26 | }).catch(console.error); 27 | }).catch(console.error); 28 | 29 | close = () => this.client?.disconnect().then(() => { 30 | console.log(`Disconnected from store: ${this.config.REDIS_URL}`); 31 | this.client = null; 32 | }).catch(console.error); 33 | } -------------------------------------------------------------------------------- /promo/src/store/StoreError.ts: -------------------------------------------------------------------------------- 1 | export namespace StoreError { 2 | export const client = new Error("Unavailable client"); 3 | } -------------------------------------------------------------------------------- /proto/authentication.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package authentication; 4 | 5 | option go_package = "."; 6 | 7 | message SignInByPhoneNumberRequest { 8 | string phone_number = 1; 9 | } 10 | 11 | message SignInByPhoneNumberResponse { 12 | int64 retry_at = 1; 13 | } 14 | 15 | message ConfirmPhoneNumberRequest{ 16 | string phone_number = 1; 17 | string confirmation_code = 2; 18 | } 19 | 20 | message ConfirmPhoneNumberResponse{ 21 | string access_token = 1; 22 | string refresh_token = 2; 23 | } 24 | 25 | message SignOutRequest { 26 | string access_token = 1; 27 | string refresh_token = 2; 28 | } 29 | 30 | message SignOutResponse { 31 | } 32 | 33 | message VerifyAccessRequest { 34 | string access_token = 1; 35 | } 36 | 37 | message VerifyAccessResponse { 38 | string id = 1; 39 | } 40 | 41 | message RefreshTokenRequest { 42 | string access_token = 1; 43 | string refresh_token = 2; 44 | } 45 | 46 | message RefreshTokenResponse { 47 | string access_token = 1; 48 | string refresh_token = 2; 49 | } 50 | 51 | service AuthenticationService { 52 | rpc SignInByPhoneNumber(SignInByPhoneNumberRequest) returns (SignInByPhoneNumberResponse); 53 | rpc ConfirmPhoneNumber(ConfirmPhoneNumberRequest) returns (ConfirmPhoneNumberResponse); 54 | rpc SignOut(SignOutRequest) returns (SignOutResponse); 55 | rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse); 56 | rpc VerifyAccess(VerifyAccessRequest) returns (VerifyAccessResponse); 57 | } -------------------------------------------------------------------------------- /proto/cart.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package cart; 4 | 5 | option go_package = "."; 6 | 7 | message CartItem { 8 | string id = 1; 9 | int32 quantity = 2; 10 | int64 added_at = 3; 11 | } 12 | 13 | message GetCartRequest { 14 | string cart_id = 1; 15 | } 16 | 17 | message GetCartResponse { 18 | repeated CartItem items = 1; 19 | } 20 | 21 | message ClearCartRequest{ 22 | string cart_id = 1; 23 | } 24 | 25 | message ClearCartResponse{ 26 | string cart_id = 1; 27 | } 28 | 29 | message IncreaseItemQuantityRequest { 30 | string cart_id = 1; 31 | string item_id = 2; 32 | } 33 | 34 | message IncreaseItemQuantityResponse { 35 | CartItem item = 1; 36 | } 37 | 38 | message DecreaseItemQuantityRequest{ 39 | string cart_id = 1; 40 | string item_id = 2; 41 | } 42 | 43 | message DecreaseItemQuantityResponse{ 44 | optional CartItem item = 1; 45 | } 46 | 47 | service CartService{ 48 | rpc getCart(GetCartRequest) returns (GetCartResponse); 49 | rpc clearCart(ClearCartRequest) returns (ClearCartResponse); 50 | rpc increaseItemQuantity(IncreaseItemQuantityRequest) returns (IncreaseItemQuantityResponse); 51 | rpc decreaseItemQuantity(DecreaseItemQuantityRequest) returns (DecreaseItemQuantityResponse); 52 | } -------------------------------------------------------------------------------- /proto/confirmation.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package confirmation; 4 | 5 | option go_package = "."; 6 | 7 | message SendPhoneNumberConfirmationRequest { 8 | string phone_number = 1; 9 | } 10 | 11 | message SendPhoneNumberConfirmationResponse { 12 | int64 retry_at = 1; 13 | } 14 | 15 | message VerifyPhoneNumberConfirmationRequest { 16 | string phone_number = 1; 17 | string confirmation_code = 2; 18 | } 19 | 20 | message VerifyPhoneNumberConfirmationResponse { 21 | string phone_number = 1; 22 | } 23 | 24 | service ConfirmationService { 25 | rpc SendPhoneNumberConfirmation(SendPhoneNumberConfirmationRequest) returns(SendPhoneNumberConfirmationResponse); 26 | rpc VerifyPhoneNumberConfirmation(VerifyPhoneNumberConfirmationRequest) returns(VerifyPhoneNumberConfirmationResponse); 27 | } -------------------------------------------------------------------------------- /proto/profile.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package profile; 4 | 5 | option go_package = "."; 6 | 7 | message Profile { 8 | string id = 1; 9 | string name = 2; 10 | repeated string addresses = 3; 11 | } 12 | 13 | message CreateProfileRequest{ 14 | Profile profile = 1; 15 | } 16 | 17 | message CreateProfileResponse{ 18 | string id = 1; 19 | } 20 | 21 | message GetProfileByIdRequest{ 22 | string id = 1; 23 | } 24 | 25 | message GetProfileByIdResponse{ 26 | Profile profile = 1; 27 | } 28 | 29 | message UpdateProfileRequest{ 30 | Profile profile = 1; 31 | } 32 | 33 | message UpdateProfileResponse{ 34 | Profile profile = 1; 35 | } 36 | 37 | message RemoveProfileRequest{ 38 | string id = 1; 39 | } 40 | 41 | message RemoveProfileResponse{ 42 | string id = 1; 43 | } 44 | 45 | service ProfileService { 46 | rpc createProfile(CreateProfileRequest) returns (CreateProfileResponse); 47 | rpc getProfileById(GetProfileByIdRequest) returns (GetProfileByIdResponse); 48 | rpc updateProfile(UpdateProfileRequest) returns (UpdateProfileResponse); 49 | rpc removeProfile(RemoveProfileRequest) returns (RemoveProfileResponse); 50 | } -------------------------------------------------------------------------------- /proto/promo.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package promo; 4 | 5 | option go_package = "."; 6 | 7 | message Promo{ 8 | string value = 1; 9 | bool reusable = 2; 10 | float required_amount = 3; 11 | repeated string category_ids = 4; 12 | repeated string product_ids = 5; 13 | bool free_shipping = 6; 14 | int64 expiration_time = 7; 15 | } 16 | 17 | message InsertPromoRequest{ 18 | string value = 1; 19 | bool reusable = 2; 20 | float required_amount = 3; 21 | repeated string category_ids = 4; 22 | repeated string product_ids = 5; 23 | bool free_shipping = 6; 24 | int64 expiration_time = 7; 25 | } 26 | 27 | message InsertPromoResponse{ 28 | Promo promo = 1; 29 | } 30 | 31 | message GetPromoRequest{ 32 | string value = 1; 33 | } 34 | 35 | message GetPromoResponse{ 36 | Promo promo = 1; 37 | } 38 | 39 | message RemovePromoRequest{ 40 | string value = 1; 41 | } 42 | 43 | message RemovePromoResponse{ 44 | string value = 1; 45 | } 46 | 47 | service PromoService{ 48 | rpc insertPromo(InsertPromoRequest) returns (InsertPromoResponse); 49 | rpc getPromo(GetPromoRequest) returns (GetPromoResponse); 50 | rpc removePromo(RemovePromoRequest) returns (RemovePromoResponse); 51 | } -------------------------------------------------------------------------------- /proto/search.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package search; 4 | 5 | option go_package = "."; 6 | 7 | message SearchItem { 8 | string id = 1; 9 | string sku = 2; 10 | string name = 3; 11 | string description = 4; 12 | bytes image_bytes = 5; 13 | float price = 6; 14 | float discount = 7; 15 | float weight = 8; 16 | int32 quantity = 9; 17 | repeated string tags = 10; 18 | int64 created_at = 11; 19 | int64 updated_at = 12; 20 | } 21 | 22 | message SearchRequest { 23 | string query = 1; 24 | } 25 | 26 | message SearchResponse { 27 | repeated SearchItem items = 1; 28 | } 29 | 30 | service SearchService { 31 | rpc search(SearchRequest) returns (SearchResponse); 32 | } -------------------------------------------------------------------------------- /proto/token.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package token; 4 | 5 | option go_package = "."; 6 | 7 | message GenerateTokenRequest { 8 | string id = 1; 9 | } 10 | 11 | message GenerateTokenResponse { 12 | string access_token = 1; 13 | string refresh_token = 2; 14 | } 15 | 16 | message VerifyTokenRequest { 17 | string token = 1; 18 | } 19 | 20 | message VerifyTokenResponse{ 21 | string id = 1; 22 | } 23 | 24 | message RevokeTokenRequest{ 25 | string token = 1; 26 | } 27 | 28 | message RevokeTokenResponse{ 29 | string token = 1; 30 | } 31 | 32 | service TokenService { 33 | rpc GenerateToken(GenerateTokenRequest) returns (GenerateTokenResponse); 34 | rpc VerifyToken(VerifyTokenRequest) returns (VerifyTokenResponse); 35 | rpc RevokeToken(RevokeTokenRequest) returns (RevokeTokenResponse); 36 | } -------------------------------------------------------------------------------- /search/.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /generated/ -------------------------------------------------------------------------------- /search/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine 2 | WORKDIR /build 3 | COPY . . 4 | RUN apk update && apk add protoc 5 | CMD ["rm","-r","generated"] 6 | CMD ["mkdir","-p","generated"] 7 | CMD ["protoc", "--go_out=generated", "--go-grpc_out=generated", "--proto_path=proto", "proto/*.proto"] 8 | RUN go get -d -v ./... 9 | RUN go build -o target ./main 10 | 11 | FROM alpine:latest 12 | COPY --from=0 build/config/prod.env . 13 | COPY --from=0 build/target . 14 | CMD ["./target","-production"] -------------------------------------------------------------------------------- /search/amqp/consumer.go: -------------------------------------------------------------------------------- 1 | package amqp 2 | 3 | import ( 4 | "github.com/rabbitmq/amqp091-go" 5 | "log" 6 | ) 7 | 8 | type Consumer[T any] struct { 9 | Channel *amqp091.Channel 10 | Name string 11 | Callback func(T) 12 | } 13 | 14 | func NewConsumer[T any](channel *amqp091.Channel, name string) Consumer[T] { 15 | return Consumer[T]{Channel: channel, Name: name} 16 | } 17 | 18 | func (c *Consumer[T]) Start(callback func(bytes []byte)) { 19 | _, err := c.Channel.QueueDeclare(c.Name, false, false, false, false, nil) 20 | if err != nil { 21 | log.Println(err) 22 | } 23 | addedQueue, err := c.Channel.Consume(c.Name, c.Name, false, true, false, false, nil) 24 | if err != nil { 25 | log.Println(err) 26 | } 27 | go func() { 28 | for added := range addedQueue { 29 | callback(added.Body) 30 | } 31 | }() 32 | } 33 | 34 | func (c *Consumer[T]) Stop() { 35 | err := c.Channel.Cancel(c.Name, false) 36 | if err != nil { 37 | log.Println(err) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /search/amqp/queue.go: -------------------------------------------------------------------------------- 1 | package amqp 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | mq "github.com/rabbitmq/amqp091-go" 7 | "log" 8 | ) 9 | 10 | type Queue struct { 11 | connection *mq.Connection 12 | channels map[string]*mq.Channel 13 | } 14 | 15 | func NewClient(address string) Queue { 16 | connection, err := mq.Dial(address) 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | fmt.Printf("Connected to message queue: %s\n", address) 21 | return Queue{ 22 | connection: connection, 23 | channels: make(map[string]*mq.Channel), 24 | } 25 | } 26 | 27 | func (m *Queue) Disconnect() { 28 | if err := m.connection.Close(); err != nil { 29 | log.Fatal(err) 30 | } 31 | fmt.Printf("Disconnected from message queue: %s\n", m.connection.RemoteAddr()) 32 | } 33 | 34 | func (m *Queue) OpenChannel(name string) { 35 | channel, err := m.connection.Channel() 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | m.channels[name] = channel 40 | } 41 | 42 | func (m *Queue) CloseChannel(name string) { 43 | if err := m.channels[name].Close(); err != nil { 44 | log.Fatal(err) 45 | } 46 | delete(m.channels, name) 47 | } 48 | 49 | func (m *Queue) UseChannel(name string) (*mq.Channel, error) { 50 | if channel := m.channels[name]; channel != nil { 51 | return channel, nil 52 | } 53 | return nil, errors.New("undefined channel") 54 | } 55 | -------------------------------------------------------------------------------- /search/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | "log" 6 | ) 7 | 8 | type Config struct { 9 | ServiceName string `mapstructure:"SERVICE_NAME"` 10 | ServerAddress string `mapstructure:"SERVER_ADDRESS"` 11 | ElasticHostname string `mapstructure:"ELASTIC_HOSTNAME"` 12 | ElasticPort string `mapstructure:"ELASTIC_PORT"` 13 | AmqpHostname string `mapstructure:"AMQP_HOSTNAME"` 14 | AmqpPort string `mapstructure:"AMQP_PORT"` 15 | AmqpChannelCatalog string `mapstructure:"AMQP_CHANNEL_CATALOG"` 16 | } 17 | 18 | func LoadConfig(name string) (config Config, err error) { 19 | viper.AddConfigPath(".") 20 | viper.AddConfigPath("./config") 21 | viper.SetConfigType("env") 22 | viper.SetConfigName(name) 23 | if err = viper.ReadInConfig(); err != nil { 24 | log.Fatal(err) 25 | } 26 | if err = viper.Unmarshal(&config); err != nil { 27 | log.Fatal(err) 28 | } 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /search/config/dev.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=search 2 | SERVER_ADDRESS=0.0.0.0:8007 3 | ELASTIC_HOSTNAME=0.0.0.0 4 | ELASTIC_PORT=9200 5 | AMQP_HOSTNAME=0.0.0.0 6 | AMQP_PORT=5672 7 | AMQP_CHANNEL_CATALOG=catalog -------------------------------------------------------------------------------- /search/config/prod.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=search 2 | SERVER_ADDRESS=0.0.0.0:8007 3 | ELASTIC_HOSTNAME=elastic 4 | ELASTIC_PORT=9200 5 | AMQP_HOSTNAME=rabbitmq 6 | AMQP_PORT=5672 7 | AMQP_CHANNEL_CATALOG=catalog -------------------------------------------------------------------------------- /search/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | search: 4 | build: . 5 | depends_on: 6 | - search_elasticsearch 7 | - search_rabbitmq 8 | volumes: 9 | - ./:/data 10 | restart: on-failure 11 | ports: 12 | - '8007:8007' 13 | search_elasticsearch: 14 | image: elasticsearch:8.6.0 15 | restart: on-failure 16 | ports: 17 | - '9200:9200' 18 | search_rabbitmq: 19 | image: rabbitmq:latest 20 | restart: on-failure 21 | ports: 22 | - '5672:5672' -------------------------------------------------------------------------------- /search/elastic/client.go: -------------------------------------------------------------------------------- 1 | package elastic 2 | 3 | import ( 4 | "github.com/elastic/elastic-transport-go/v8/elastictransport" 5 | "github.com/elastic/go-elasticsearch/v8" 6 | "log" 7 | "os" 8 | ) 9 | 10 | type Elastic struct { 11 | Client *elasticsearch.Client 12 | } 13 | 14 | func NewElastic(addresses []string) Elastic { 15 | client, err := elasticsearch.NewClient(elasticsearch.Config{ 16 | Addresses: addresses, 17 | Logger: &elastictransport.TextLogger{Output: os.Stdout}, 18 | }) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | return Elastic{Client: client} 23 | } 24 | 25 | func (e *Elastic) CreateIndex(name string) error { 26 | res, err := e.Client.Indices.Exists([]string{name}) 27 | if err != nil { 28 | return err 29 | } 30 | if res.StatusCode == 200 { 31 | return nil 32 | } 33 | if _, err := e.Client.Indices.Create(name); err != nil { 34 | return err 35 | } 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /search/fn/fn.go: -------------------------------------------------------------------------------- 1 | package fn 2 | 3 | func Map[T any, R any](in []*T, f func(*T) *R) (out []*R) { 4 | for _, i := range in { 5 | out = append(out, f(i)) 6 | } 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /search/go.mod: -------------------------------------------------------------------------------- 1 | module search 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/elastic/elastic-transport-go/v8 v8.0.0-alpha 7 | github.com/elastic/go-elasticsearch/v8 v8.0.0 8 | github.com/rabbitmq/amqp091-go v1.6.0 9 | github.com/spf13/viper v1.14.0 10 | google.golang.org/grpc v1.51.0 11 | google.golang.org/protobuf v1.28.1 12 | ) 13 | 14 | require ( 15 | github.com/fsnotify/fsnotify v1.6.0 // indirect 16 | github.com/golang/protobuf v1.5.2 // indirect 17 | github.com/hashicorp/hcl v1.0.0 // indirect 18 | github.com/magiconair/properties v1.8.6 // indirect 19 | github.com/mitchellh/mapstructure v1.5.0 // indirect 20 | github.com/pelletier/go-toml v1.9.5 // indirect 21 | github.com/pelletier/go-toml/v2 v2.0.5 // indirect 22 | github.com/spf13/afero v1.9.2 // indirect 23 | github.com/spf13/cast v1.5.0 // indirect 24 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 25 | github.com/spf13/pflag v1.0.5 // indirect 26 | github.com/subosito/gotenv v1.4.1 // indirect 27 | golang.org/x/net v0.0.0-20221014081412-f15817d10f9b // indirect 28 | golang.org/x/sys v0.0.0-20220908164124-27713097b956 // indirect 29 | golang.org/x/text v0.4.0 // indirect 30 | google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e // indirect 31 | gopkg.in/ini.v1 v1.67.0 // indirect 32 | gopkg.in/yaml.v2 v2.4.0 // indirect 33 | gopkg.in/yaml.v3 v3.0.1 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /search/search/dispatcher.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | "search/amqp" 8 | ) 9 | 10 | type Dispatcher struct { 11 | ctx context.Context 12 | done chan error 13 | useCase UseCase 14 | } 15 | 16 | func NewDispatcher(useCase UseCase) Dispatcher { 17 | return Dispatcher{context.Background(), make(chan error), useCase} 18 | } 19 | 20 | func (d *Dispatcher) Insert(consumer amqp.Consumer[Item]) (err error) { 21 | consumer.Start(func(bytes []byte) { 22 | var item = Item{} 23 | if err := json.Unmarshal(bytes, &item); err != nil { 24 | log.Println(err) 25 | return 26 | } 27 | id, err := d.useCase.Insert(d.ctx, item) 28 | if err != nil { 29 | log.Println(err) 30 | return 31 | } 32 | log.Printf("Document with id %s was successfully inserted", *id) 33 | }) 34 | return nil 35 | } 36 | 37 | func (d *Dispatcher) Update(consumer amqp.Consumer[Item]) (err error) { 38 | consumer.Start(func(bytes []byte) { 39 | var item = Item{} 40 | if err := json.Unmarshal(bytes, &item); err != nil { 41 | log.Println(err) 42 | return 43 | } 44 | id, err := d.useCase.Insert(d.ctx, item) 45 | if err != nil { 46 | log.Println(err) 47 | return 48 | } 49 | log.Printf("Document with id %s was successfully updated", *id) 50 | }) 51 | return nil 52 | } 53 | 54 | func (d *Dispatcher) Remove(consumer amqp.Consumer[Item]) (err error) { 55 | consumer.Start(func(bytes []byte) { 56 | var item = Item{} 57 | if err := json.Unmarshal(bytes, &item); err != nil { 58 | log.Println(err) 59 | return 60 | } 61 | id, err := d.useCase.Insert(d.ctx, item) 62 | if err != nil { 63 | log.Println(err) 64 | return 65 | } 66 | log.Printf("Document with id %s was successfully removed", *id) 67 | }) 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /search/search/interactor.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type UseCase interface { 8 | Search(ctx context.Context, query string) ([]*Item, error) 9 | Insert(ctx context.Context, item Item) (*string, error) 10 | Update(ctx context.Context, item Item) (*string, error) 11 | Remove(ctx context.Context, id string) (*string, error) 12 | } 13 | 14 | type UseCaseImpl struct { 15 | repository Repository 16 | } 17 | 18 | func NewUseCase(repository Repository) UseCase { 19 | return &UseCaseImpl{repository} 20 | } 21 | 22 | func (u *UseCaseImpl) Search(ctx context.Context, query string) ([]*Item, error) { 23 | return u.repository.Search(ctx, query) 24 | } 25 | 26 | func (u *UseCaseImpl) Insert(ctx context.Context, item Item) (*string, error) { 27 | return u.repository.Insert(ctx, item) 28 | } 29 | 30 | func (u *UseCaseImpl) Update(ctx context.Context, item Item) (*string, error) { 31 | return u.repository.Update(ctx, item) 32 | } 33 | 34 | func (u *UseCaseImpl) Remove(ctx context.Context, id string) (*string, error) { 35 | return u.repository.Remove(ctx, id) 36 | } 37 | -------------------------------------------------------------------------------- /search/search/item.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | type Item struct { 4 | Id string 5 | Sku string 6 | Name string 7 | Description string 8 | ImageBytes []byte 9 | Price float32 10 | Discount float32 11 | Weight float32 12 | Quantity int32 13 | Tags []string 14 | CreatedAt int64 15 | UpdatedAt int64 16 | } 17 | -------------------------------------------------------------------------------- /search/search/mapper.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | pb "search/generated" 5 | ) 6 | 7 | type Mapper interface { 8 | MessageToEntity(message *pb.SearchItem) *Item 9 | EntityToMessage(entity *Item) *pb.SearchItem 10 | } 11 | 12 | type MapperImpl struct { 13 | } 14 | 15 | func NewMapper() Mapper { 16 | return &MapperImpl{} 17 | } 18 | 19 | func (m *MapperImpl) MessageToEntity(message *pb.SearchItem) *Item { 20 | return &Item{ 21 | Id: message.GetId(), 22 | Sku: message.GetSku(), 23 | Name: message.GetName(), 24 | Description: message.GetDescription(), 25 | ImageBytes: message.GetImageBytes(), 26 | Price: message.GetPrice(), 27 | Discount: message.GetDiscount(), 28 | Weight: message.GetWeight(), 29 | Quantity: message.GetQuantity(), 30 | Tags: message.GetTags(), 31 | CreatedAt: message.GetCreatedAt(), 32 | UpdatedAt: message.GetUpdatedAt(), 33 | } 34 | } 35 | 36 | func (m *MapperImpl) EntityToMessage(entity *Item) *pb.SearchItem { 37 | return &pb.SearchItem{ 38 | Id: entity.Id, 39 | Sku: entity.Sku, 40 | Name: entity.Name, 41 | Description: entity.Description, 42 | ImageBytes: entity.ImageBytes, 43 | Price: entity.Price, 44 | Discount: entity.Discount, 45 | Weight: entity.Weight, 46 | Quantity: entity.Quantity, 47 | Tags: entity.Tags, 48 | CreatedAt: entity.CreatedAt, 49 | UpdatedAt: entity.UpdatedAt, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /search/search/service.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "context" 5 | "google.golang.org/grpc/codes" 6 | "google.golang.org/grpc/status" 7 | "search/fn" 8 | pb "search/generated" 9 | ) 10 | 11 | type ServiceImpl struct { 12 | pb.UnimplementedSearchServiceServer 13 | useCase UseCase 14 | mapper Mapper 15 | } 16 | 17 | func NewService(useCase UseCase, mapper Mapper) pb.SearchServiceServer { 18 | return &ServiceImpl{useCase: useCase, mapper: mapper} 19 | } 20 | 21 | func (s *ServiceImpl) Search(ctx context.Context, request *pb.SearchRequest) (*pb.SearchResponse, error) { 22 | reqQuery := request.GetQuery() 23 | if reqQuery == "" { 24 | return nil, status.Error(codes.InvalidArgument, "Value cannot be empty") 25 | } 26 | items, err := s.useCase.Search(ctx, reqQuery) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return &pb.SearchResponse{Items: fn.Map(items, s.mapper.EntityToMessage)}, nil 31 | } 32 | -------------------------------------------------------------------------------- /search/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "google.golang.org/grpc" 5 | "log" 6 | "net" 7 | ) 8 | 9 | type Server struct { 10 | Address string 11 | } 12 | 13 | func (s *Server) Launch(bind func(*grpc.Server), opts ...grpc.ServerOption) { 14 | server := grpc.NewServer(opts...) 15 | bind(server) 16 | listener, err := net.Listen("tcp", s.Address) 17 | if err != nil { 18 | log.Fatal(err) 19 | } else { 20 | log.Printf("Server started on: %s", s.Address) 21 | } 22 | if err = server.Serve(listener); err != nil { 23 | log.Fatal(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /token/.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /generated/ -------------------------------------------------------------------------------- /token/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine 2 | WORKDIR /build 3 | COPY . . 4 | RUN apk update && apk add protoc 5 | CMD ["rm","-r","generated"] 6 | CMD ["mkdir","-p","generated"] 7 | CMD ["protoc", "--go_out=generated", "--go-grpc_out=generated", "--proto_path=proto", "proto/*.proto"] 8 | RUN go get -d -v ./... 9 | RUN go build -o target ./main 10 | 11 | FROM alpine:latest 12 | COPY --from=0 build/config/prod.env . 13 | COPY --from=0 build/target . 14 | CMD ["./target","-production"] -------------------------------------------------------------------------------- /token/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | "log" 6 | ) 7 | 8 | type Config struct { 9 | ServiceName string `mapstructure:"SERVICE_NAME"` 10 | ServerAddress string `mapstructure:"SERVER_ADDRESS"` 11 | RedisHostname string `mapstructure:"REDIS_HOSTNAME"` 12 | RedisPort string `mapstructure:"REDIS_PORT"` 13 | ApiKey string `mapstructure:"API_KEY"` 14 | SecretKey string `mapstructure:"SECRET_KEY"` 15 | } 16 | 17 | func LoadConfig(name string) (config Config, err error) { 18 | viper.AddConfigPath(".") 19 | viper.AddConfigPath("./config") 20 | viper.SetConfigType("env") 21 | viper.SetConfigName(name) 22 | if err = viper.ReadInConfig(); err != nil { 23 | log.Fatal(err) 24 | } 25 | if err = viper.Unmarshal(&config); err != nil { 26 | log.Fatal(err) 27 | } 28 | return 29 | } 30 | -------------------------------------------------------------------------------- /token/config/dev.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=token 2 | SERVER_ADDRESS=0.0.0.0:8081 3 | REDIS_HOSTNAME=0.0.0.0 4 | REDIS_PORT=6379 5 | API_KEY=aW52b2x2ZWRzcHJpbmdjb25zb25hbnRzaGFsbG1lbWJlcmJ5bW9ua2V5ZXhlcmNpc2U= 6 | SECRET_KEY=c3ByaW5naXRhZ3JlZXdvb2xlbmdpbmVlcnBlcmhhcmJvcmZyZWVkb21rZXB0cGxhbm4= -------------------------------------------------------------------------------- /token/config/prod.env: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=token 2 | SERVER_ADDRESS=0.0.0.0:8081 3 | REDIS_HOSTNAME=redis 4 | REDIS_PORT=6379 5 | API_KEY=aW52b2x2ZWRzcHJpbmdjb25zb25hbnRzaGFsbG1lbWJlcmJ5bW9ua2V5ZXhlcmNpc2U= 6 | SECRET_KEY=c3ByaW5naXRhZ3JlZXdvb2xlbmdpbmVlcnBlcmhhcmJvcmZyZWVkb21rZXB0cGxhbm4= -------------------------------------------------------------------------------- /token/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | token: 4 | build: . 5 | depends_on: 6 | - token_redis 7 | volumes: 8 | - ./:/data 9 | restart: on-failure 10 | ports: 11 | - '8081:8081' 12 | token_redis: 13 | image: redis:latest 14 | restart: on-failure 15 | ports: 16 | - '6379:6379' -------------------------------------------------------------------------------- /token/main/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "google.golang.org/grpc" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | "log" 11 | "token/config" 12 | pb "token/generated" 13 | "token/server" 14 | "token/store" 15 | "token/token" 16 | ) 17 | 18 | func main() { 19 | productionMode := flag.Bool("production", false, "enable production mode") 20 | flag.Parse() 21 | cfgName := func() string { 22 | if *productionMode { 23 | return "prod" 24 | } 25 | return "dev" 26 | }() 27 | cfg, err := config.LoadConfig(cfgName) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | client := store.NewClient(context.Background(), fmt.Sprintf("%s:%s", cfg.RedisHostname, cfg.RedisPort)) 33 | defer client.Close() 34 | 35 | accountRepository := token.NewRepository(cfg, client) 36 | accountUseCase := token.NewUseCase(accountRepository) 37 | accountService := token.NewService(accountUseCase) 38 | 39 | authInterceptor := server.NewInterceptor("Authorization", func(ctx context.Context, header string) error { 40 | if header != cfg.ApiKey { 41 | return status.Errorf(codes.Unauthenticated, "Invalid API key") 42 | } 43 | return nil 44 | }) 45 | 46 | grpcServer := server.Server{Address: cfg.ServerAddress} 47 | grpcServer.Launch(func(server *grpc.Server) { 48 | pb.RegisterTokenServiceServer(server, accountService) 49 | }, authInterceptor) 50 | } 51 | -------------------------------------------------------------------------------- /token/server/interceptor.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "google.golang.org/grpc" 6 | "google.golang.org/grpc/codes" 7 | "google.golang.org/grpc/metadata" 8 | "google.golang.org/grpc/status" 9 | ) 10 | 11 | func NewInterceptor(name string, callback func(ctx context.Context, header string) error) grpc.ServerOption { 12 | return grpc.UnaryInterceptor(func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { 13 | md, ok := metadata.FromIncomingContext(ctx) 14 | if !ok { 15 | return nil, status.Errorf(codes.InvalidArgument, "Error reading metadata") 16 | } 17 | header := md.Get(name) 18 | if len(header) < 1 { 19 | return nil, status.Errorf(codes.InvalidArgument, "Error reading header") 20 | } 21 | if err := callback(ctx, header[0]); err != nil { 22 | return nil, status.Errorf(codes.Internal, "Error processing header") 23 | } 24 | return handler(ctx, req) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /token/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "google.golang.org/grpc" 5 | "log" 6 | "net" 7 | ) 8 | 9 | type Server struct { 10 | Address string 11 | } 12 | 13 | func (s *Server) Launch(bind func(*grpc.Server), opts ...grpc.ServerOption) { 14 | server := grpc.NewServer(opts...) 15 | bind(server) 16 | listener, err := net.Listen("tcp", s.Address) 17 | if err != nil { 18 | log.Fatal(err) 19 | } else { 20 | log.Printf("Server started on: %s", s.Address) 21 | } 22 | if err = server.Serve(listener); err != nil { 23 | log.Fatal(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /token/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "github.com/go-redis/redis/v9" 6 | "log" 7 | ) 8 | 9 | func NewClient(ctx context.Context, address string) *redis.Client { 10 | client := redis.NewClient(&redis.Options{Addr: address}) 11 | if _, err := client.Ping(ctx).Result(); err != nil { 12 | log.Fatal(err) 13 | } 14 | log.Printf("Connected to store: %s", address) 15 | return client 16 | } 17 | -------------------------------------------------------------------------------- /token/token/claims.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | type Claims struct { 4 | Id string 5 | IssuedAt int64 6 | ExpirationTime int64 7 | } 8 | -------------------------------------------------------------------------------- /token/token/interactor.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type UseCase interface { 8 | GenerateTokenPair(ctx context.Context, payload string) (*Pair, error) 9 | VerifyToken(ctx context.Context, tokenString string) (*Claims, error) 10 | RevokeToken(ctx context.Context, token string) (*string, error) 11 | } 12 | 13 | type UseCaseImpl struct { 14 | repository Repository 15 | } 16 | 17 | func NewUseCase(repository Repository) UseCase { 18 | return &UseCaseImpl{repository: repository} 19 | } 20 | 21 | func (u *UseCaseImpl) GenerateTokenPair(ctx context.Context, payload string) (*Pair, error) { 22 | return u.repository.GenerateTokenPair(ctx, payload) 23 | } 24 | 25 | func (u *UseCaseImpl) VerifyToken(ctx context.Context, token string) (*Claims, error) { 26 | return u.repository.VerifyToken(ctx, token) 27 | } 28 | 29 | func (u *UseCaseImpl) RevokeToken(ctx context.Context, token string) (*string, error) { 30 | claims, err := u.repository.VerifyToken(ctx, token) 31 | if err != nil { 32 | return nil, err 33 | } 34 | return u.repository.RevokeToken(ctx, token, claims.ExpirationTime) 35 | } 36 | -------------------------------------------------------------------------------- /token/token/pair.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | type Pair struct { 4 | AccessToken string 5 | RefreshToken string 6 | } 7 | -------------------------------------------------------------------------------- /token/token/service.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "context" 5 | "google.golang.org/grpc/codes" 6 | "google.golang.org/grpc/status" 7 | pb "token/generated" 8 | ) 9 | 10 | type ServiceImpl struct { 11 | pb.UnimplementedTokenServiceServer 12 | useCase UseCase 13 | } 14 | 15 | func NewService(useCase UseCase) pb.TokenServiceServer { 16 | return &ServiceImpl{useCase: useCase} 17 | } 18 | 19 | func (s *ServiceImpl) GenerateToken(ctx context.Context, request *pb.GenerateTokenRequest) (*pb.GenerateTokenResponse, error) { 20 | reqId := request.GetId() 21 | if reqId == "" { 22 | return nil, status.Error(codes.InvalidArgument, "Value cannot be empty") 23 | } 24 | tokenPair, err := s.useCase.GenerateTokenPair(ctx, reqId) 25 | if err != nil { 26 | return nil, err 27 | } 28 | return &pb.GenerateTokenResponse{AccessToken: tokenPair.AccessToken, RefreshToken: tokenPair.RefreshToken}, nil 29 | } 30 | 31 | func (s *ServiceImpl) VerifyToken(ctx context.Context, request *pb.VerifyTokenRequest) (*pb.VerifyTokenResponse, error) { 32 | reqAccessToken := request.GetToken() 33 | if reqAccessToken == "" { 34 | return nil, status.Error(codes.InvalidArgument, "Value cannot be empty") 35 | } 36 | claims, err := s.useCase.VerifyToken(ctx, reqAccessToken) 37 | if err != nil { 38 | return nil, err 39 | } 40 | return &pb.VerifyTokenResponse{Id: claims.Id}, nil 41 | } 42 | func (s *ServiceImpl) RevokeToken(ctx context.Context, request *pb.RevokeTokenRequest) (*pb.RevokeTokenResponse, error) { 43 | reqToken := request.GetToken() 44 | if reqToken == "" { 45 | return nil, status.Error(codes.InvalidArgument, "Value cannot be empty") 46 | } 47 | if _, err := s.useCase.RevokeToken(ctx, reqToken); err != nil { 48 | return nil, err 49 | } 50 | return &pb.RevokeTokenResponse{Token: reqToken}, nil 51 | } 52 | --------------------------------------------------------------------------------