diff --git a/.gitignore b/.gitignore index 3eacbb8..3d45c6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /bin /generated /.idea +/mocks-generated /cover.out /cover.out.tmp diff --git a/Makefile b/Makefile index 9575e95..7355e9e 100644 --- a/Makefile +++ b/Makefile @@ -88,7 +88,8 @@ bin-deps: .bin-deps go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1 && \ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2.0 && \ go install golang.org/x/tools/cmd/goimports@v0.19.0 && \ - go install github.com/envoyproxy/protoc-gen-validate@v1.2.1 + go install github.com/envoyproxy/protoc-gen-validate@v1.2.1 && \ + go install go.uber.org/mock/mockgen@latest .create-bin: rm -rf ./bin @@ -106,11 +107,15 @@ fast-generate: .generate rm -rf ./docs/spec mkdir -p ./docs/spec + rm -rf ./mocks-generated + mkdir ./mocks-generated + rm -rf ~/.easyp/ (PATH="$(PATH):$(LOCAL_BIN)" && $(EASYP_BIN) mod download && $(EASYP_BIN) generate) $(GOIMPORTS_BIN) -w . + go generate ./... build: go mod tidy diff --git a/api/library/library.proto b/api/library/library.proto index e69de29..1deb1d3 100644 --- a/api/library/library.proto +++ b/api/library/library.proto @@ -0,0 +1,170 @@ +syntax = "proto3"; + +import "google/api/annotations.proto"; +import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +package library; + +option go_package = "github.com/project/library/pkg/api/library;library"; + +service Library { + rpc AddBook(AddBookRequest) returns (AddBookResponse) { + option (google.api.http) = { + post: "/v1/library/book" + body: "*" + }; + } + + rpc UpdateBook(UpdateBookRequest) returns (UpdateBookResponse) { + option (google.api.http) = { + put: "/v1/library/book" + body: "*" + }; + } + + rpc GetBookInfo(GetBookInfoRequest) returns (GetBookInfoResponse) { + option (google.api.http) = { + get: "/v1/library/book/{id}" + }; + } + + rpc RegisterAuthor(RegisterAuthorRequest) returns (RegisterAuthorResponse) { + option (google.api.http) = { + post: "/v1/library/author" + body: "*" + }; + } + + rpc ChangeAuthorInfo(ChangeAuthorInfoRequest) returns (ChangeAuthorInfoResponse) { + option (google.api.http) = { + put: "/v1/library/author" + body: "*" + }; + } + + rpc GetAuthorInfo(GetAuthorInfoRequest) returns (GetAuthorInfoResponse) { + option (google.api.http) = { + get: "/v1/library/author/{id}" + }; + } + + rpc GetAuthorBooks(GetAuthorBooksRequest) returns (stream Book) { + option (google.api.http) = { + get: "/v1/library/author_books/{author_id}" + }; + } +} + +message Book { + string id = 1 [ + (validate.rules).string.uuid = true + ]; + + string name = 2; + + repeated string author_id = 3 [ + (validate.rules).repeated = { + min_items: 0, + items: { + string: {uuid: true} + } + } + ]; + + google.protobuf.Timestamp created_at = 4; + google.protobuf.Timestamp updated_at = 5; +} + +message AddBookRequest { + string name = 1; + + repeated string author_ids = 2 [ + (validate.rules).repeated = { + min_items: 0, + items: { + string: {uuid: true} + } + } + ]; +} + +message AddBookResponse { + Book book = 1; +} + +message UpdateBookRequest { + string id = 1 [ + (validate.rules).string.uuid = true + ]; + + string name = 2; + + repeated string author_ids = 3 [ + (validate.rules).repeated = { + min_items: 0, + items: { + string: {uuid: true} + } + } + ]; +} + +message UpdateBookResponse {} + +message GetBookInfoRequest { + string id = 1 [ + (validate.rules).string.uuid = true + ]; +} + +message GetBookInfoResponse { + Book book = 1; +} + +message RegisterAuthorRequest { + string name = 1 [ + (validate.rules).string = { + pattern: "^[A-Za-z0-9]+( [A-Za-z0-9]+)*$", + min_len: 1, + max_len: 512 + } + ]; +} + +message RegisterAuthorResponse { + string id = 1; +} + +message ChangeAuthorInfoRequest { + string id = 1 [ + (validate.rules).string.uuid = true + ]; + string name = 2 [ + (validate.rules).string = { + pattern: "^[A-Za-z0-9]+( [A-Za-z0-9]+)*$", + min_len: 1, + max_len: 512 + } + ]; +} + +message ChangeAuthorInfoResponse {} + + +message GetAuthorInfoRequest { + string id = 1 [ + (validate.rules).string.uuid = true + ]; +} + +message GetAuthorInfoResponse { + string id = 1; + string name = 2; +} + +message GetAuthorBooksRequest { + string author_id = 1 [ + (validate.rules).string.uuid = true + ]; +} \ No newline at end of file diff --git a/cmd/library/main.go b/cmd/library/main.go index a46007b..631b9f7 100644 --- a/cmd/library/main.go +++ b/cmd/library/main.go @@ -8,7 +8,7 @@ import ( ) func main() { - cfg, err := config.NewConfig() + cfg, err := config.New() if err != nil { log.Fatalf("can not get application config: %s", err) diff --git a/config/config.go b/config/config.go index 4007154..1016ee6 100644 --- a/config/config.go +++ b/config/config.go @@ -1,18 +1,152 @@ package config +import ( + "errors" + "fmt" + "net" + "net/url" + "os" +) + type ( Config struct { GRPC + PG } GRPC struct { Port string `env:"GRPC_PORT"` GatewayPort string `env:"GRPC_GATEWAY_PORT"` } + + PG struct { + URL string + Host string `env:"POSTGRES_HOST"` + Port string `env:"POSTGRES_PORT"` + DB string `env:"POSTGRES_DB"` + User string `env:"POSTGRES_USER"` + Password string `env:"POSTGRES_PASSWORD"` + MaxConn string `env:"POSTGRES_MAX_CONN"` + } +) + +var ( + ErrGRPCPortNotSet = errors.New("GRPC_PORT environment variable not set") + ErrGRPCGatewayPortNotSet = errors.New("GRPC_GATEWAY_PORT environment variable not set") + ErrPostgresHostNotSet = errors.New("POSTGRES_HOST environment variable not set") + ErrPostgresPortNotSet = errors.New("POSTGRES_PORT environment variable not set") + ErrPostgresDBNotSet = errors.New("POSTGRES_DB environment variable not set") + ErrPostgresUserNotSet = errors.New("POSTGRES_USER environment variable not set") + ErrPostgresPasswordNotSet = errors.New("POSTGRES_PASSWORD environment variable not set") + ErrPostgresMaxConnNotSet = errors.New("POSTGRES_MAX_CONN environment variable not set") ) -func NewConfig() (*Config, error) { +func New() (*Config, error) { cfg := &Config{} + if err := cfg.readGrpcPort(); err != nil { + return nil, err + } + if err := cfg.readGrpcGatewayPort(); err != nil { + return nil, err + } + if err := cfg.readPGHost(); err != nil { + return nil, err + } + if err := cfg.readPGUser(); err != nil { + return nil, err + } + if err := cfg.readPGPort(); err != nil { + return nil, err + } + if err := cfg.readPGPassword(); err != nil { + return nil, err + } + if err := cfg.readPGDB(); err != nil { + return nil, err + } + if err := cfg.readPGMaxConn(); err != nil { + return nil, err + } + + cfg.PG.URL = fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", + url.PathEscape(cfg.PG.User), + url.PathEscape(cfg.PG.Password), + net.JoinHostPort(cfg.PG.Host, cfg.PG.Port), + cfg.PG.DB, + ) + return cfg, nil } + +func (config *Config) readGrpcPort() error { + var ok bool + config.GRPC.Port, ok = os.LookupEnv("GRPC_PORT") + if !ok || config.GRPC.Port == "" { + return ErrGRPCPortNotSet + } + return nil +} + +func (config *Config) readGrpcGatewayPort() error { + var ok bool + config.GRPC.GatewayPort, ok = os.LookupEnv("GRPC_GATEWAY_PORT") + if !ok || config.GRPC.GatewayPort == "" { + return ErrGRPCGatewayPortNotSet + } + return nil +} + +func (config *Config) readPGHost() error { + var ok bool + config.PG.Host, ok = os.LookupEnv("POSTGRES_HOST") + if !ok || config.PG.Host == "" { + return ErrPostgresHostNotSet + } + return nil +} + +func (config *Config) readPGPort() error { + var ok bool + config.PG.Port, ok = os.LookupEnv("POSTGRES_PORT") + if !ok || config.PG.Port == "" { + return ErrPostgresPortNotSet + } + return nil +} + +func (config *Config) readPGUser() error { + var ok bool + config.PG.User, ok = os.LookupEnv("POSTGRES_USER") + if !ok || config.PG.User == "" { + return ErrPostgresUserNotSet + } + return nil +} + +func (config *Config) readPGDB() error { + var ok bool + config.PG.DB, ok = os.LookupEnv("POSTGRES_DB") + if !ok || config.PG.DB == "" { + return ErrPostgresDBNotSet + } + return nil +} + +func (config *Config) readPGPassword() error { + var ok bool + config.PG.Password, ok = os.LookupEnv("POSTGRES_PASSWORD") + if !ok || config.PG.Password == "" { + return ErrPostgresPasswordNotSet + } + return nil +} + +func (config *Config) readPGMaxConn() error { + var ok bool + config.PG.MaxConn, ok = os.LookupEnv("POSTGRES_MAX_CONN") + if !ok || config.PG.MaxConn == "" { + return ErrPostgresMaxConnNotSet + } + return nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..63c3f18 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,94 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConfigSuccess(t *testing.T) { + t.Run("Success", func(t *testing.T) { + setup(t) + cfg, err := New() + require.NoError(t, err) + require.Equal(t, "8081", cfg.GRPC.Port) + require.Equal(t, "8080", cfg.GRPC.GatewayPort) + require.Equal(t, "localhost", cfg.PG.Host) + require.Equal(t, "5432", cfg.PG.Port) + require.Equal(t, "go", cfg.PG.Password) + require.Equal(t, "10", cfg.PG.MaxConn) + require.Equal(t, "nikongo", cfg.PG.User) + require.Equal(t, "godb", cfg.PG.DB) + + }) +} + +func TestConfigFailures(t *testing.T) { + tests := []struct { + name string + envVar string + error error + }{ + { + + "GRPC_PORT not set", + "GRPC_PORT", + ErrGRPCPortNotSet, + }, + { + "GRPC_GATEWAY_PORT not set", + "GRPC_GATEWAY_PORT", + ErrGRPCGatewayPortNotSet, + }, + { + "POSTGRES_HOST not set", + "POSTGRES_HOST", + ErrPostgresHostNotSet, + }, + { + "POSTGRES_PORT not set", + "POSTGRES_PORT", + ErrPostgresPortNotSet, + }, + { + "POSTGRES_DB not set", + "POSTGRES_DB", + ErrPostgresDBNotSet, + }, + { + "POSTGRES_USER not set", + "POSTGRES_USER", + ErrPostgresUserNotSet, + }, + { + "POSTGRES_PASSWORD not set", + "POSTGRES_PASSWORD", + ErrPostgresPasswordNotSet, + }, + { + "POSTGRES_MAX_CONN not set", + "POSTGRES_MAX_CONN", + ErrPostgresMaxConnNotSet, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setup(t) + t.Setenv(test.envVar, "") + _, err := New() + require.ErrorIs(t, err, test.error) + }) + } +} + +func setup(t *testing.T) { + t.Setenv("GRPC_PORT", "8081") + t.Setenv("GRPC_GATEWAY_PORT", "8080") + t.Setenv("POSTGRES_HOST", "localhost") + t.Setenv("POSTGRES_PORT", "5432") + t.Setenv("POSTGRES_DB", "godb") + t.Setenv("POSTGRES_USER", "nikongo") + t.Setenv("POSTGRES_PASSWORD", "go") + t.Setenv("POSTGRES_MAX_CONN", "10") +} diff --git a/db/migrations/001_create_author_table.sql b/db/migrations/001_create_author_table.sql new file mode 100644 index 0000000..e6f9543 --- /dev/null +++ b/db/migrations/001_create_author_table.sql @@ -0,0 +1,32 @@ +-- +goose Up +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE author +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION update_author_timestamp() RETURNS TRIGGER AS +$$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + + +CREATE OR REPLACE TRIGGER trigger_update_author_timestamp + BEFORE UPDATE + ON author + FOR EACH ROW +EXECUTE FUNCTION update_author_timestamp(); + + +-- +goose Down +DROP TABLE author; +DROP FUNCTION update_author_timestamp; \ No newline at end of file diff --git a/db/migrations/002_create_author_name_index.sql b/db/migrations/002_create_author_name_index.sql new file mode 100644 index 0000000..0fc4b9c --- /dev/null +++ b/db/migrations/002_create_author_name_index.sql @@ -0,0 +1,5 @@ +-- +goose Up +CREATE INDEX index_author_name ON author(name); + +-- +goose Down +DROP INDEX index_author_name; \ No newline at end of file diff --git a/db/migrations/003_create_book_table.sql b/db/migrations/003_create_book_table.sql new file mode 100644 index 0000000..5f1bd34 --- /dev/null +++ b/db/migrations/003_create_book_table.sql @@ -0,0 +1,28 @@ +-- +goose Up +CREATE TABLE book +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION update_book_timestamp() RETURNS TRIGGER AS +$$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +CREATE OR REPLACE TRIGGER trigger_update_book_timestamp + BEFORE UPDATE + ON book + FOR EACH ROW +EXECUTE FUNCTION update_book_timestamp(); + +-- +goose Down +DROP TABLE book; +DROP FUNCTION update_book_timestamp; \ No newline at end of file diff --git a/db/migrations/004_create_book_name_index.sql b/db/migrations/004_create_book_name_index.sql new file mode 100644 index 0000000..b21130d --- /dev/null +++ b/db/migrations/004_create_book_name_index.sql @@ -0,0 +1,5 @@ +-- +goose Up +CREATE INDEX idx_book_name ON book(name); + +-- +goose Down +DROP INDEX idx_book_name; \ No newline at end of file diff --git a/db/migrations/005_create_author_book_table.sql b/db/migrations/005_create_author_book_table.sql new file mode 100644 index 0000000..dd87b58 --- /dev/null +++ b/db/migrations/005_create_author_book_table.sql @@ -0,0 +1,10 @@ +-- +goose Up +CREATE TABLE author_book +( + author_id UUID REFERENCES author(id) ON DELETE CASCADE, + book_id UUID REFERENCES book(id) ON DELETE CASCADE, + PRIMARY KEY (author_id, book_id) +); + +-- +goose Down +DROP TABLE author_book; \ No newline at end of file diff --git a/db/migrations/006_create_book_id_index.sql b/db/migrations/006_create_book_id_index.sql new file mode 100644 index 0000000..7b96b28 --- /dev/null +++ b/db/migrations/006_create_book_id_index.sql @@ -0,0 +1,5 @@ +-- +goose Up +CREATE INDEX index_author_book_book_id ON author_book(book_id); + +-- +goose Down +DROP INDEX index_author_book_book_id; \ No newline at end of file diff --git a/docs/spec/api/library/library.swagger.json b/docs/spec/api/library/library.swagger.json new file mode 100644 index 0000000..0af3af5 --- /dev/null +++ b/docs/spec/api/library/library.swagger.json @@ -0,0 +1,389 @@ +{ + "swagger": "2.0", + "info": { + "title": "api/library/library.proto", + "version": "version not set" + }, + "tags": [ + { + "name": "Library" + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/v1/library/author": { + "post": { + "operationId": "Library_RegisterAuthor", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/libraryRegisterAuthorResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/libraryRegisterAuthorRequest" + } + } + ], + "tags": [ + "Library" + ] + }, + "put": { + "operationId": "Library_ChangeAuthorInfo", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/libraryChangeAuthorInfoResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/libraryChangeAuthorInfoRequest" + } + } + ], + "tags": [ + "Library" + ] + } + }, + "/v1/library/author/{id}": { + "get": { + "operationId": "Library_GetAuthorInfo", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/libraryGetAuthorInfoResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "Library" + ] + } + }, + "/v1/library/author_books/{authorId}": { + "get": { + "operationId": "Library_GetAuthorBooks", + "responses": { + "200": { + "description": "A successful response.(streaming responses)", + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/libraryBook" + }, + "error": { + "$ref": "#/definitions/rpcStatus" + } + }, + "title": "Stream result of libraryBook" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "authorId", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "Library" + ] + } + }, + "/v1/library/book": { + "post": { + "operationId": "Library_AddBook", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/libraryAddBookResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/libraryAddBookRequest" + } + } + ], + "tags": [ + "Library" + ] + }, + "put": { + "operationId": "Library_UpdateBook", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/libraryUpdateBookResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/libraryUpdateBookRequest" + } + } + ], + "tags": [ + "Library" + ] + } + }, + "/v1/library/book/{id}": { + "get": { + "operationId": "Library_GetBookInfo", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/libraryGetBookInfoResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "Library" + ] + } + } + }, + "definitions": { + "libraryAddBookRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "authorIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "libraryAddBookResponse": { + "type": "object", + "properties": { + "book": { + "$ref": "#/definitions/libraryBook" + } + } + }, + "libraryBook": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "authorId": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "libraryChangeAuthorInfoRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "libraryChangeAuthorInfoResponse": { + "type": "object" + }, + "libraryGetAuthorInfoResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "libraryGetBookInfoResponse": { + "type": "object", + "properties": { + "book": { + "$ref": "#/definitions/libraryBook" + } + } + }, + "libraryRegisterAuthorRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "libraryRegisterAuthorResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + }, + "libraryUpdateBookRequest": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "authorIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "libraryUpdateBookResponse": { + "type": "object" + }, + "protobufAny": { + "type": "object", + "properties": { + "@type": { + "type": "string" + } + }, + "additionalProperties": {} + }, + "rpcStatus": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protobufAny" + } + } + } + } + } +} diff --git a/go.mod b/go.mod index 675e386..80e613f 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,19 @@ module github.com/project/library go 1.23.4 require ( - github.com/envoyproxy/protoc-gen-validate v1.0.4 + github.com/envoyproxy/protoc-gen-validate v1.1.0 github.com/google/uuid v1.6.0 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 github.com/jackc/pgx/v5 v5.7.1 + github.com/lib/pq v1.10.9 github.com/pressly/goose/v3 v3.24.1 github.com/samber/lo v1.47.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 go.uber.org/zap v1.27.0 - google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 - google.golang.org/grpc v1.66.0 - google.golang.org/protobuf v1.34.2 + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb + google.golang.org/grpc v1.70.0 + google.golang.org/protobuf v1.36.5 ) require ( @@ -21,17 +23,18 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/kr/pretty v0.3.1 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/pkg/errors v0.9.1 github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + go.uber.org/mock v0.5.0 go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/net v0.33.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2dc3999..d8dfa7b 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,22 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A= -github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM= +github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -24,20 +31,22 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY= github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= @@ -51,31 +60,43 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= +go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= +go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= +go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= +go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= +go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= +go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8= -google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= -google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c= -google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= +google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/.golangci.yaml b/golangci.yaml similarity index 100% rename from .golangci.yaml rename to golangci.yaml diff --git a/internal/app/app.go b/internal/app/app.go index 9be7ce9..9517702 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,13 +1,88 @@ package app import ( + "context" + "net" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/jackc/pgx/v5/pgxpool" "github.com/project/library/config" + "github.com/project/library/db" + generated "github.com/project/library/generated/api/library" + "github.com/project/library/internal/controller" + "github.com/project/library/internal/usecase/library" + "github.com/project/library/internal/usecase/repository" "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/reflection" ) func Run(logger *zap.Logger, cfg *config.Config) { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + dbPool, err := pgxpool.New(ctx, cfg.PG.URL) + + if err != nil { + logger.Error("can not create pgxpool", zap.Error(err)) + } + + defer dbPool.Close() + db.SetupPostgres(dbPool, logger) + + postgres := repository.New(dbPool, logger) + useCases := library.New(logger, postgres, postgres) + + ctrl := controller.New(logger, useCases, useCases) + + go runRest(ctx, cfg, logger) + go runGrpc(cfg, logger, ctrl) + + <-ctx.Done() } -func runRest() {} +func runRest(ctx context.Context, cfg *config.Config, logger *zap.Logger) { + mux := runtime.NewServeMux() + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + + address := "localhost:" + cfg.GRPC.Port + err := generated.RegisterLibraryHandlerFromEndpoint(ctx, mux, address, opts) + + if err != nil { + logger.Error("can not register grpc gateway", zap.Error(err)) + os.Exit(-1) + } -func runGrpc() {} + gatewayPort := ":" + cfg.GatewayPort + logger.Info("gateway listening at port", zap.String("port", gatewayPort)) + + if err = http.ListenAndServe(gatewayPort, mux); err != nil { + logger.Error("gateway listen error", zap.Error(err)) + } +} + +func runGrpc(cfg *config.Config, logger *zap.Logger, libraryService generated.LibraryServer) { + port := ":" + cfg.GRPC.Port + lis, err := net.Listen("tcp", port) + + if err != nil { + logger.Error("can not open tcp socket", zap.Error(err)) + os.Exit(-1) + } + + s := grpc.NewServer() + reflection.Register(s) + + generated.RegisterLibraryServer(s, libraryService) + + logger.Info("grpc server listening at port", zap.String("port", port)) + + if err = s.Serve(lis); err != nil { + logger.Error("grpc server listen error", zap.Error(err)) + } +} diff --git a/internal/controller/add_book.go b/internal/controller/add_book.go index b0b429f..c6e1a77 100644 --- a/internal/controller/add_book.go +++ b/internal/controller/add_book.go @@ -1 +1,40 @@ package controller + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) AddBook(ctx context.Context, request *library.AddBookRequest) (*library.AddBookResponse, error) { + impl.logger.Info("AddBook controller: started") + defer impl.logger.Info("AddBook controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("AddBook controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + book, err := impl.booksUseCase.AddBook(ctx, request.GetName(), impl.stringsToUUIDs(request.GetAuthorIds())) + + impl.logger.Info("AddBook controller: TIME " + book.CreatedAt.String()) + if err != nil { + impl.logger.Error("AddBook controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + impl.logger.Info("AddBook controller: TIME " + book.CreatedAt.String()) + + return &library.AddBookResponse{ + Book: &library.Book{ + Id: book.ID.String(), + Name: book.Name, + AuthorId: book.AuthorIDs.Strings(), + CreatedAt: timestamppb.New(book.CreatedAt), + UpdatedAt: timestamppb.New(book.UpdatedAt), + }, + }, nil +} diff --git a/internal/controller/change_author_info.go b/internal/controller/change_author_info.go index b0b429f..459c9c1 100644 --- a/internal/controller/change_author_info.go +++ b/internal/controller/change_author_info.go @@ -1 +1,29 @@ package controller + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) ChangeAuthorInfo(ctx context.Context, request *library.ChangeAuthorInfoRequest) (*library.ChangeAuthorInfoResponse, error) { + impl.logger.Info("ChangeAuthorInfo controller: started") + defer impl.logger.Info("ChangeAuthorInfo controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("ChangeAuthorInfo controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + authorID := uuid.Must(uuid.Parse(request.GetId())) + _, err := impl.authorsUseCase.UpdateAuthor(ctx, authorID, request.GetName()) + if err != nil { + impl.logger.Error("ChangeAuthorInfo controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + + return &library.ChangeAuthorInfoResponse{}, nil +} diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go new file mode 100644 index 0000000..8ac33c8 --- /dev/null +++ b/internal/controller/controller_test.go @@ -0,0 +1,660 @@ +package controller + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "github.com/project/library/internal/entity" + custommocks "github.com/project/library/mocks-custom" + generatedmocks "github.com/project/library/mocks-generated" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestController_AddBook(t *testing.T) { + t.Parallel() + + basicBook := entity.Book{ + ID: uuid.Nil, + Name: "book", + AuthorIDs: []uuid.UUID{uuid.Nil}, + } + + nilBook := entity.Book{} + + basicBookRequest := library.AddBookRequest{ + Name: basicBook.Name, + AuthorIds: basicBook.AuthorIDs.Strings(), + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().AddBook(ctx, basicBook.Name, basicBook.AuthorIDs). + Return(basicBook, nil) + + result, err := controllerSetup.service.AddBook(ctx, &basicBookRequest) + require.Equal(t, result.GetBook().GetId(), basicBook.ID.String()) + require.Equal(t, result.GetBook().GetName(), basicBook.Name) + require.NoError(t, err) + }) + + t.Run("book already exist", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().AddBook(ctx, basicBook.Name, basicBook.AuthorIDs). + Return(nilBook, entity.ErrBookAlreadyExists) + + result, err := controllerSetup.service.AddBook(ctx, &basicBookRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.AlreadyExists, entity.ErrBookAlreadyExists.Error())) + }) + + t.Run("author not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().AddBook(ctx, basicBook.Name, basicBook.AuthorIDs). + Return(nilBook, entity.ErrAuthorNotFound) + + result, err := controllerSetup.service.AddBook(ctx, &basicBookRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrAuthorNotFound.Error())) + }) + + t.Run("author invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.AddBook(ctx, &library.AddBookRequest{ + Name: basicBook.Name, + AuthorIds: []string{"invalid-uuid"}, + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +func TestController_GetBook(t *testing.T) { + t.Parallel() + + basicBook := entity.Book{ + ID: uuid.Nil, + Name: "book", + AuthorIDs: []uuid.UUID{uuid.Nil}, + } + + nilBook := entity.Book{} + + basicBookRequest := library.GetBookInfoRequest{ + Id: basicBook.ID.String(), + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().GetBook(ctx, basicBook.ID). + Return(basicBook, nil) + + result, err := controllerSetup.service.GetBookInfo(ctx, &basicBookRequest) + require.Equal(t, result.GetBook().GetId(), basicBook.ID.String()) + require.Equal(t, result.GetBook().GetName(), basicBook.Name) + require.NoError(t, err) + }) + + t.Run("book not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().GetBook(ctx, basicBook.ID). + Return(nilBook, entity.ErrBookNotFound) + + result, err := controllerSetup.service.GetBookInfo(ctx, &basicBookRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrBookNotFound.Error())) + }) + + t.Run("book invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.GetBookInfo(ctx, &library.GetBookInfoRequest{ + Id: "not uuid", + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +func TestController_UpdateBook(t *testing.T) { + t.Parallel() + + basicBook := entity.Book{ + ID: uuid.Nil, + Name: "book", + AuthorIDs: []uuid.UUID{uuid.Nil}, + } + + nilBook := entity.Book{} + + updatedBasicBook := entity.Book{ + ID: basicBook.ID, + Name: basicBook.Name + " update", + AuthorIDs: []uuid.UUID{uuid.Nil, uuid.Max}, + } + + basicBookRequest := library.UpdateBookRequest{ + Id: updatedBasicBook.ID.String(), + Name: updatedBasicBook.Name, + AuthorIds: updatedBasicBook.AuthorIDs.Strings(), + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().UpdateBook(ctx, updatedBasicBook.ID, updatedBasicBook.Name, updatedBasicBook.AuthorIDs). + Return(updatedBasicBook, nil) + + result, err := controllerSetup.service.UpdateBook(ctx, &basicBookRequest) + require.NotNil(t, result) + require.NoError(t, err) + }) + + t.Run("book not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().UpdateBook(ctx, updatedBasicBook.ID, updatedBasicBook.Name, updatedBasicBook.AuthorIDs). + Return(nilBook, entity.ErrBookNotFound) + + result, err := controllerSetup.service.UpdateBook(ctx, &basicBookRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrBookNotFound.Error())) + }) + + t.Run("author not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.booksUseCaseMock.EXPECT().UpdateBook(ctx, updatedBasicBook.ID, updatedBasicBook.Name, updatedBasicBook.AuthorIDs). + Return(nilBook, entity.ErrAuthorNotFound) + + result, err := controllerSetup.service.UpdateBook(ctx, &basicBookRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrAuthorNotFound.Error())) + }) + + t.Run("book invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.UpdateBook(ctx, &library.UpdateBookRequest{ + Id: "not uuid", + Name: basicBook.Name, + AuthorIds: []string{}, + }) + require.Nil(t, result) + require.Error(t, err) + }) + + t.Run("author invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.UpdateBook(ctx, &library.UpdateBookRequest{ + Id: basicBook.ID.String(), + Name: basicBook.Name, + AuthorIds: []string{"not uuid"}, + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +func TestController_GetAuthorBooks(t *testing.T) { + vasya := uuid.Nil + + book1 := entity.Book{ID: uuid.Nil, Name: "Book 1", AuthorIDs: []uuid.UUID{vasya}} + book2 := entity.Book{ID: uuid.Max, Name: "Book 2", AuthorIDs: []uuid.UUID{vasya}} + basicRequest := library.GetAuthorBooksRequest{ + AuthorId: vasya.String(), + } + + t.Run("success", func(t *testing.T) { + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + + controllerSetup := createControllerSetup(controller) + + controllerSetup.booksUseCaseMock.EXPECT().GetAuthorBooks(gomock.Any(), vasya). + Return([]entity.Book{book1, book2}, nil) + + streamMock := custommocks.NewMockLibraryGetAuthorBooksServer(nil, nil) + + err := controllerSetup.service.GetAuthorBooks(&basicRequest, streamMock) + require.NoError(t, err) + require.Equal(t, len(streamMock.SentBooks), 2) + }) + + t.Run("stream send error", func(t *testing.T) { + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + + controllerSetup := createControllerSetup(controller) + + controllerSetup.booksUseCaseMock.EXPECT().GetAuthorBooks(gomock.Any(), vasya). + Return([]entity.Book{book1, book2}, nil) + + expectedErr := errors.New("send error") + streamMock := custommocks.NewMockLibraryGetAuthorBooksServer(nil, expectedErr) + + err := controllerSetup.service.GetAuthorBooks(&basicRequest, streamMock) + require.ErrorIs(t, expectedErr, err) + require.Equal(t, len(streamMock.SentBooks), 0) + }) + + t.Run("author invalid uuid", func(t *testing.T) { + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + + controllerSetup := createControllerSetup(controller) + streamMock := custommocks.NewMockLibraryGetAuthorBooksServer(nil, nil) + err := controllerSetup.service.GetAuthorBooks(&library.GetAuthorBooksRequest{ + AuthorId: "not-uuid", + }, streamMock) + require.Equal(t, len(streamMock.SentBooks), 0) + require.Error(t, err) + }) +} + +func TestController_AddAuthor(t *testing.T) { + t.Parallel() + + basicAuthor := entity.Author{ + ID: uuid.Nil, + Name: "author", + } + + nilAuthor := entity.Author{} + + basicAuthorRequest := library.RegisterAuthorRequest{ + Name: basicAuthor.Name, + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().AddAuthor(ctx, basicAuthor.Name). + Return(basicAuthor, nil) + + result, err := controllerSetup.service.RegisterAuthor(ctx, &basicAuthorRequest) + require.Equal(t, result, &library.RegisterAuthorResponse{ + Id: basicAuthor.ID.String(), + }) + require.NoError(t, err) + }) + + t.Run("author already exist", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().AddAuthor(ctx, basicAuthor.Name). + Return(nilAuthor, entity.ErrAuthorAlreadyExists) + + result, err := controllerSetup.service.RegisterAuthor(ctx, &basicAuthorRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.AlreadyExists, entity.ErrAuthorAlreadyExists.Error())) + }) + + t.Run("author invalid name (unsupported symbol)", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.RegisterAuthor(ctx, &library.RegisterAuthorRequest{ + Name: "@", + }) + require.Nil(t, result) + require.Error(t, err) + }) + + t.Run("author invalid name (invalid length)", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.RegisterAuthor(ctx, &library.RegisterAuthorRequest{ + Name: "", + }) + require.Nil(t, result) + require.Error(t, err) + + result, err = controllerSetup.service.RegisterAuthor(ctx, &library.RegisterAuthorRequest{ + Name: strings.Repeat("a", 1000), + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +func TestController_GetAuthor(t *testing.T) { + t.Parallel() + + basicAuthor := entity.Author{ + ID: uuid.Nil, + Name: "author", + } + + nilAuthor := entity.Author{} + + basicAuthorRequest := library.GetAuthorInfoRequest{ + Id: basicAuthor.ID.String(), + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().GetAuthor(ctx, basicAuthor.ID). + Return(basicAuthor, nil) + + result, err := controllerSetup.service.GetAuthorInfo(ctx, &basicAuthorRequest) + require.Equal(t, result, &library.GetAuthorInfoResponse{ + Id: basicAuthor.ID.String(), + Name: basicAuthor.Name, + }) + require.NoError(t, err) + }) + + t.Run("author not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().GetAuthor(ctx, basicAuthor.ID). + Return(nilAuthor, entity.ErrAuthorNotFound) + + result, err := controllerSetup.service.GetAuthorInfo(ctx, &basicAuthorRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrAuthorNotFound.Error())) + }) + + t.Run("author invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.GetAuthorInfo(ctx, &library.GetAuthorInfoRequest{ + Id: "not uuid", + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +func TestController_UpdateAuthor(t *testing.T) { + t.Parallel() + + basicAuthor := entity.Author{ + ID: uuid.Nil, + Name: "author", + } + + nilAuthor := entity.Author{} + + updatedBasicAuthor := entity.Author{ + ID: basicAuthor.ID, + Name: basicAuthor.Name + " update", + } + + basicAuthorRequest := library.ChangeAuthorInfoRequest{ + Id: updatedBasicAuthor.ID.String(), + Name: updatedBasicAuthor.Name, + } + + t.Run("success", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().UpdateAuthor(ctx, updatedBasicAuthor.ID, updatedBasicAuthor.Name). + Return(updatedBasicAuthor, nil) + + result, err := controllerSetup.service.ChangeAuthorInfo(ctx, &basicAuthorRequest) + require.NotNil(t, result) + require.NoError(t, err) + }) + + t.Run("author not found", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + controllerSetup.authorsUseCaseMock.EXPECT().UpdateAuthor(ctx, updatedBasicAuthor.ID, updatedBasicAuthor.Name). + Return(nilAuthor, entity.ErrAuthorNotFound) + + result, err := controllerSetup.service.ChangeAuthorInfo(ctx, &basicAuthorRequest) + require.Nil(t, result) + require.ErrorIs(t, err, status.Error(codes.NotFound, entity.ErrAuthorNotFound.Error())) + }) + + t.Run("author invalid uuid", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.ChangeAuthorInfo(ctx, &library.ChangeAuthorInfoRequest{ + Id: "not uuid", + Name: "author", + }) + require.Nil(t, result) + require.Error(t, err) + }) + + t.Run("author invalid name (unsupported symbol)", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.ChangeAuthorInfo(ctx, &library.ChangeAuthorInfoRequest{ + Id: uuid.Nil.String(), + Name: "@", + }) + require.Nil(t, result) + require.Error(t, err) + }) + + t.Run("author invalid name (unsupported length)", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + controllerSetup := createControllerSetup(controller) + + result, err := controllerSetup.service.ChangeAuthorInfo(ctx, &library.ChangeAuthorInfoRequest{ + Id: uuid.Nil.String(), + Name: strings.Repeat("a", 1000), + }) + require.Nil(t, result) + require.Error(t, err) + }) +} + +type controllerSetup struct { + booksUseCaseMock generatedmocks.MockBooksUseCase + authorsUseCaseMock generatedmocks.MockAuthorsUseCase + service library.LibraryServer +} + +func createControllerSetup(controller *gomock.Controller) *controllerSetup { + booksUseCaseMock := generatedmocks.NewMockBooksUseCase(controller) + authorsUseCaseMock := generatedmocks.NewMockAuthorsUseCase(controller) + logger := zap.NewNop() + + return &controllerSetup{ + booksUseCaseMock: *booksUseCaseMock, + authorsUseCaseMock: *authorsUseCaseMock, + service: New(logger, booksUseCaseMock, authorsUseCaseMock), + } +} diff --git a/internal/controller/get_author_books.go b/internal/controller/get_author_books.go index b0b429f..e8c4d26 100644 --- a/internal/controller/get_author_books.go +++ b/internal/controller/get_author_books.go @@ -1 +1,40 @@ package controller + +import ( + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func (impl *implementation) GetAuthorBooks(request *library.GetAuthorBooksRequest, stream library.Library_GetAuthorBooksServer) error { + impl.logger.Info("GetAuthorBooks controller: started") + defer impl.logger.Info("GetAuthorBooks controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("GetAuthorBooks controller: invalid argument") + return status.Error(codes.InvalidArgument, err.Error()) + } + + authorID := uuid.Must(uuid.Parse(request.GetAuthorId())) + books, err := impl.booksUseCase.GetAuthorBooks(stream.Context(), authorID) + if err != nil { + return status.Error(codes.Internal, err.Error()) + } + + for _, book := range books { + if err := stream.Send(&library.Book{ + Id: book.ID.String(), + Name: book.Name, + AuthorId: book.AuthorIDs.Strings(), + CreatedAt: timestamppb.New(book.CreatedAt), + UpdatedAt: timestamppb.New(book.UpdatedAt), + }); err != nil { + impl.logger.Error("GetAuthorBooks controller: failed to send book - " + err.Error()) + return err + } + } + + return nil +} diff --git a/internal/controller/get_author_info.go b/internal/controller/get_author_info.go index b0b429f..e87e4bf 100644 --- a/internal/controller/get_author_info.go +++ b/internal/controller/get_author_info.go @@ -1 +1,32 @@ package controller + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) GetAuthorInfo(ctx context.Context, request *library.GetAuthorInfoRequest) (*library.GetAuthorInfoResponse, error) { + impl.logger.Info("GetAuthorInfo controller: started") + defer impl.logger.Info("GetAuthorInfo controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("GetAuthorInfo controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + authorID := uuid.Must(uuid.Parse(request.GetId())) + author, err := impl.authorsUseCase.GetAuthor(ctx, authorID) + if err != nil { + impl.logger.Error("GetAuthorInfo controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + + return &library.GetAuthorInfoResponse{ + Id: author.ID.String(), + Name: author.Name, + }, nil +} diff --git a/internal/controller/get_book_info.go b/internal/controller/get_book_info.go index b0b429f..7dac50b 100644 --- a/internal/controller/get_book_info.go +++ b/internal/controller/get_book_info.go @@ -1 +1,40 @@ package controller + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) GetBookInfo(ctx context.Context, request *library.GetBookInfoRequest) (*library.GetBookInfoResponse, error) { + impl.logger.Info("GetBookInfo controller: started") + defer impl.logger.Info("GetBookInfo controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("GetBookInfo controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + bookID := uuid.Must(uuid.Parse(request.GetId())) + book, err := impl.booksUseCase.GetBook(ctx, bookID) + if err != nil { + impl.logger.Error("GetBookInfo controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + + impl.logger.Info("AddBook controller: TIME " + book.CreatedAt.String()) + return &library.GetBookInfoResponse{ + Book: &library.Book{ + Id: book.ID.String(), + Name: book.Name, + AuthorId: book.AuthorIDs.Strings(), + CreatedAt: timestamppb.New(book.CreatedAt), + UpdatedAt: timestamppb.New(book.UpdatedAt), + }, + }, nil +} diff --git a/internal/controller/register_author.go b/internal/controller/register_author.go index b0b429f..6955292 100644 --- a/internal/controller/register_author.go +++ b/internal/controller/register_author.go @@ -1 +1,29 @@ package controller + +import ( + "context" + + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) RegisterAuthor(ctx context.Context, request *library.RegisterAuthorRequest) (*library.RegisterAuthorResponse, error) { + impl.logger.Info("RegisterAuthor controller: started") + defer impl.logger.Info("RegisterAuthor controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("RegisterAuthor controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + author, err := impl.authorsUseCase.AddAuthor(ctx, request.GetName()) + if err != nil { + impl.logger.Error("RegisterAuthor controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + + return &library.RegisterAuthorResponse{ + Id: author.ID.String(), + }, nil +} diff --git a/internal/controller/service.go b/internal/controller/service.go index b0b429f..421d1b4 100644 --- a/internal/controller/service.go +++ b/internal/controller/service.go @@ -1 +1,27 @@ package controller + +import ( + generated "github.com/project/library/generated/api/library" + "github.com/project/library/internal/usecase/library" + "go.uber.org/zap" +) + +var _ generated.LibraryServer = (*implementation)(nil) + +type implementation struct { + logger *zap.Logger + booksUseCase library.BooksUseCase + authorsUseCase library.AuthorsUseCase +} + +func New( + logger *zap.Logger, + booksUseCase library.BooksUseCase, + authorsUseCase library.AuthorsUseCase, +) *implementation { + return &implementation{ + logger: logger, + booksUseCase: booksUseCase, + authorsUseCase: authorsUseCase, + } +} diff --git a/internal/controller/update_book.go b/internal/controller/update_book.go index b0b429f..0e24163 100644 --- a/internal/controller/update_book.go +++ b/internal/controller/update_book.go @@ -1 +1,29 @@ package controller + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) UpdateBook(ctx context.Context, request *library.UpdateBookRequest) (*library.UpdateBookResponse, error) { + impl.logger.Info("UpdateBook controller: started") + defer impl.logger.Info("UpdateBook controller: finished") + + if err := request.ValidateAll(); err != nil { + impl.logger.Error("UpdateBook controller: invalid argument") + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + bookID := uuid.Must(uuid.Parse(request.GetId())) + _, err := impl.booksUseCase.UpdateBook(ctx, bookID, request.GetName(), impl.stringsToUUIDs(request.GetAuthorIds())) + if err != nil { + impl.logger.Error("UpdateBook controller: " + err.Error()) + return nil, impl.libraryToGrpcErr(err) + } + + return &library.UpdateBookResponse{}, nil +} diff --git a/internal/controller/util.go b/internal/controller/util.go index b0b429f..3f5d9a4 100644 --- a/internal/controller/util.go +++ b/internal/controller/util.go @@ -1 +1,34 @@ package controller + +import ( + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/project/library/internal/entity" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (impl *implementation) libraryToGrpcErr(err error) error { + switch { + case errors.Is(err, entity.ErrBookNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, entity.ErrBookAlreadyExists): + return status.Error(codes.AlreadyExists, err.Error()) + case errors.Is(err, entity.ErrAuthorNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, entity.ErrAuthorAlreadyExists): + return status.Error(codes.AlreadyExists, err.Error()) + default: + return status.Error(codes.Internal, err.Error()) + } +} + +// call ONLY after ValidateAll +func (impl *implementation) stringsToUUIDs(strings []string) uuid.UUIDs { + uuids := make([]uuid.UUID, 0, len(strings)) + for _, s := range strings { + u := uuid.Must(uuid.Parse(s)) + uuids = append(uuids, u) + } + return uuids +} diff --git a/internal/entity/author.go b/internal/entity/author.go index 9356433..af4a249 100644 --- a/internal/entity/author.go +++ b/internal/entity/author.go @@ -1 +1,17 @@ package entity + +import ( + "errors" + + "github.com/google/uuid" +) + +type Author struct { + ID uuid.UUID + Name string +} + +var ( + ErrAuthorNotFound = errors.New("Author not found") + ErrAuthorAlreadyExists = errors.New("Author already exists") +) diff --git a/internal/entity/book.go b/internal/entity/book.go index 9356433..fcdcda6 100644 --- a/internal/entity/book.go +++ b/internal/entity/book.go @@ -1 +1,21 @@ package entity + +import ( + "errors" + "time" + + "github.com/google/uuid" +) + +type Book struct { + ID uuid.UUID + Name string + AuthorIDs uuid.UUIDs + CreatedAt time.Time + UpdatedAt time.Time +} + +var ( + ErrBookNotFound = errors.New("book not found") + ErrBookAlreadyExists = errors.New("book already exists") +) diff --git a/internal/usecase/library/authors.go b/internal/usecase/library/authors.go index e196664..9161990 100644 --- a/internal/usecase/library/authors.go +++ b/internal/usecase/library/authors.go @@ -1 +1,28 @@ package library + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/internal/entity" +) + +func (library *libraryImpl) AddAuthor(ctx context.Context, name string) (entity.Author, error) { + library.logger.Info("AddAuthor use case: started") + defer library.logger.Info("AddAuthor use case: finished") + return library.authorsRepository.AddAuthor(ctx, entity.Author{ + Name: name, + }) +} + +func (library *libraryImpl) GetAuthor(ctx context.Context, authorID uuid.UUID) (entity.Author, error) { + library.logger.Info("GetAuthor use case: started") + defer library.logger.Info("GetAuthor use case: finished") + return library.authorsRepository.GetAuthor(ctx, authorID) +} + +func (library *libraryImpl) UpdateAuthor(ctx context.Context, authorID uuid.UUID, name string) (entity.Author, error) { + library.logger.Info("UpdateAuthor use case: started") + defer library.logger.Info("GetAuthor use case: finished") + return library.authorsRepository.UpdateAuthor(ctx, authorID, name) +} diff --git a/internal/usecase/library/books.go b/internal/usecase/library/books.go index e196664..ac110a9 100644 --- a/internal/usecase/library/books.go +++ b/internal/usecase/library/books.go @@ -1 +1,39 @@ package library + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/internal/entity" +) + +func (library *libraryImpl) AddBook(ctx context.Context, name string, authorIDs uuid.UUIDs) (entity.Book, error) { + library.logger.Info("AddBook use case: started") + defer library.logger.Info("AddBook use case: finished") + + return library.booksRepository.AddBook(ctx, entity.Book{ + Name: name, + AuthorIDs: authorIDs, + }) +} + +func (library *libraryImpl) GetBook(ctx context.Context, bookID uuid.UUID) (entity.Book, error) { + library.logger.Info("GetBook use case: started") + defer library.logger.Info("GetBook use case: finished") + + return library.booksRepository.GetBook(ctx, bookID) +} + +func (library *libraryImpl) UpdateBook(ctx context.Context, bookID uuid.UUID, name string, authorIDs uuid.UUIDs) (entity.Book, error) { + library.logger.Info("UpdateBook use case: started") + defer library.logger.Info("UpdateBook use case: finished") + + return library.booksRepository.UpdateBook(ctx, bookID, name, authorIDs) +} + +func (library *libraryImpl) GetAuthorBooks(ctx context.Context, authorID uuid.UUID) ([]entity.Book, error) { + library.logger.Info("GetAuthorBooks use case: started") + defer library.logger.Info("GetAuthorBooks use case: finished") + + return library.booksRepository.GetAuthorBooks(ctx, authorID) +} diff --git a/internal/usecase/library/interfaces.go b/internal/usecase/library/interfaces.go index e196664..04a6231 100644 --- a/internal/usecase/library/interfaces.go +++ b/internal/usecase/library/interfaces.go @@ -1 +1,46 @@ package library + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/internal/entity" + "github.com/project/library/internal/usecase/repository" + "go.uber.org/zap" +) + +//go:generate ../../../bin/mockgen -source=interfaces.go -destination=../../../mocks-generated/usecases_mock.go -package=mocks_generated + +type BooksUseCase interface { + AddBook(ctx context.Context, name string, authorIDs uuid.UUIDs) (entity.Book, error) + GetBook(ctx context.Context, bookID uuid.UUID) (entity.Book, error) + UpdateBook(ctx context.Context, bookID uuid.UUID, name string, authorIDs uuid.UUIDs) (entity.Book, error) + GetAuthorBooks(ctx context.Context, authorID uuid.UUID) ([]entity.Book, error) +} + +type AuthorsUseCase interface { + AddAuthor(ctx context.Context, name string) (entity.Author, error) + GetAuthor(ctx context.Context, authorID uuid.UUID) (entity.Author, error) + UpdateAuthor(ctx context.Context, authorID uuid.UUID, name string) (entity.Author, error) +} + +var _ BooksUseCase = (*libraryImpl)(nil) +var _ AuthorsUseCase = (*libraryImpl)(nil) + +type libraryImpl struct { + logger *zap.Logger + booksRepository repository.BooksRepository + authorsRepository repository.AuthorsRepository +} + +func New( + logger *zap.Logger, + booksRepository repository.BooksRepository, + authorsRepository repository.AuthorsRepository, +) *libraryImpl { + return &libraryImpl{ + logger: logger, + booksRepository: booksRepository, + authorsRepository: authorsRepository, + } +} diff --git a/internal/usecase/library/library_test.go b/internal/usecase/library/library_test.go new file mode 100644 index 0000000..21b9bdf --- /dev/null +++ b/internal/usecase/library/library_test.go @@ -0,0 +1,374 @@ +package library + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/project/library/internal/entity" + mocks "github.com/project/library/mocks-generated" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestLibrary_BooksUseCase(t *testing.T) { + t.Parallel() + + basicBook := entity.Book{ + ID: uuid.Nil, + Name: "book", + AuthorIDs: []uuid.UUID{uuid.Nil}, + } + + nilBook := entity.Book{} + + t.Run("AddBook", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expectedBook entity.Book + expectedError error + inputName string + inputAuthorIDs []uuid.UUID + }{ + { + name: "success", + expectedBook: basicBook, + expectedError: nil, + inputName: basicBook.Name, + inputAuthorIDs: basicBook.AuthorIDs, + }, + { + name: "author not found", + expectedBook: nilBook, + expectedError: entity.ErrAuthorNotFound, + inputName: basicBook.Name, + inputAuthorIDs: basicBook.AuthorIDs, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.booksRepositoryMock.EXPECT().AddBook(ctx, gomock.Any()). + Return(test.expectedBook, test.expectedError) + + result, err := testSetup.booksUseCase.AddBook(ctx, test.inputName, test.inputAuthorIDs) + require.Equal(t, result, test.expectedBook) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) + + t.Run("GetBook", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + book entity.Book + expectedBook entity.Book + expectedError error + }{ + { + name: "success", + book: basicBook, + expectedBook: basicBook, + expectedError: nil, + }, + { + name: "book bot found", + book: basicBook, + expectedBook: nilBook, + expectedError: entity.ErrBookNotFound, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.booksRepositoryMock.EXPECT().GetBook(ctx, gomock.Any()). + Return(test.expectedBook, test.expectedError) + + result, err := testSetup.booksUseCase.GetBook(ctx, test.book.ID) + require.Equal(t, result, test.expectedBook) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) + + t.Run("UpdateBook", func(t *testing.T) { + t.Parallel() + + updatedBook := entity.Book{ + ID: basicBook.ID, + Name: basicBook.Name + " update", + AuthorIDs: []uuid.UUID{uuid.Nil, uuid.Max}, + } + + tests := []struct { + name string + book entity.Book + expectedBook entity.Book + expectedError error + }{ + { + name: "success", + book: updatedBook, + expectedBook: updatedBook, + expectedError: nil, + }, + { + name: "book bot found", + book: updatedBook, + expectedBook: nilBook, + expectedError: entity.ErrBookNotFound, + }, + { + name: "author not found", + book: updatedBook, + expectedBook: nilBook, + expectedError: entity.ErrAuthorNotFound, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.booksRepositoryMock.EXPECT().UpdateBook(ctx, test.book.ID, test.book.Name, test.book.AuthorIDs). + Return(test.expectedBook, test.expectedError) + + result, err := testSetup.booksUseCase.UpdateBook(ctx, test.book.ID, test.book.Name, test.book.AuthorIDs) + require.Equal(t, result, test.expectedBook) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) + + t.Run("GetAuthorBooks", func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + defer controller.Finish() + + vasya := uuid.Nil + + book1 := entity.Book{ID: uuid.Nil, Name: "Book 1", AuthorIDs: []uuid.UUID{vasya}} + book2 := entity.Book{ID: uuid.Max, Name: "Book 2", AuthorIDs: []uuid.UUID{vasya}} + + t.Run("success", func(t *testing.T) { + ctx := context.Background() + testSetup := createSetup(controller) + testSetup.booksRepositoryMock.EXPECT(). + GetAuthorBooks(ctx, vasya). + Return([]entity.Book{book1, book2}, nil) + + result, err := testSetup.booksUseCase.GetAuthorBooks(context.Background(), vasya) + require.NoError(t, err) + require.ElementsMatch(t, []entity.Book{book1, book2}, result) + }) + + t.Run("author not found", func(t *testing.T) { + ctx := context.Background() + testSetup := createSetup(controller) + testSetup.booksRepositoryMock.EXPECT(). + GetAuthorBooks(ctx, vasya). + Return(nil, entity.ErrAuthorNotFound) + + result, err := testSetup.booksUseCase.GetAuthorBooks(context.Background(), vasya) + require.ErrorIs(t, entity.ErrAuthorNotFound, err) + require.Nil(t, result) + }) + }) +} + +func TestLibrary_AuthorsUseCase(t *testing.T) { + t.Parallel() + + basicAuthor := entity.Author{ + ID: uuid.Nil, + Name: "author", + } + + nilAuthor := entity.Author{} + + t.Run("AddAuthor", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + author entity.Author + expectedAuthor entity.Author + expectedError error + }{ + { + name: "success", + author: basicAuthor, + expectedAuthor: basicAuthor, + expectedError: nil, + }, + { + name: "author already exists", + author: basicAuthor, + expectedAuthor: nilAuthor, + expectedError: entity.ErrAuthorAlreadyExists, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.authorsRepositoryMock.EXPECT().AddAuthor(ctx, gomock.Any()). + Return(test.expectedAuthor, test.expectedError) + + result, err := testSetup.authorsUseCase.AddAuthor(ctx, test.author.Name) + require.Equal(t, result, test.expectedAuthor) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) + + t.Run("GetAuthor", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + author entity.Author + expectedAuthor entity.Author + expectedError error + }{ + { + name: "success", + author: basicAuthor, + expectedAuthor: basicAuthor, + expectedError: nil, + }, + { + name: "author not found", + author: basicAuthor, + expectedAuthor: nilAuthor, + expectedError: entity.ErrAuthorNotFound, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.authorsRepositoryMock.EXPECT().GetAuthor(ctx, test.author.ID). + Return(test.expectedAuthor, test.expectedError) + + result, err := testSetup.authorsUseCase.GetAuthor(ctx, test.author.ID) + require.Equal(t, result, test.expectedAuthor) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) + + t.Run("UpdateAuthor", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + author entity.Author + expectedAuthor entity.Author + expectedError error + }{ + { + name: "success", + author: basicAuthor, + expectedAuthor: entity.Author{ + ID: basicAuthor.ID, + Name: basicAuthor.Name + " update", + }, + expectedError: nil, + }, + { + name: "author not found", + author: basicAuthor, + expectedError: entity.ErrAuthorNotFound, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + controller := gomock.NewController(t) + t.Cleanup(func() { + controller.Finish() + }) + ctx := context.Background() + + testSetup := createSetup(controller) + testSetup.authorsRepositoryMock.EXPECT().UpdateAuthor(ctx, test.author.ID, test.author.Name). + Return(test.expectedAuthor, test.expectedError) + + author, err := testSetup.authorsUseCase.UpdateAuthor(ctx, test.author.ID, test.author.Name) + require.Equal(t, author, test.expectedAuthor) + require.ErrorIs(t, err, test.expectedError) + }) + } + }) +} + +type setup struct { + booksRepositoryMock *mocks.MockBooksRepository + authorsRepositoryMock *mocks.MockAuthorsRepository + booksUseCase BooksUseCase + authorsUseCase AuthorsUseCase +} + +func createSetup(controller *gomock.Controller) setup { + authorsRepository := mocks.NewMockAuthorsRepository(controller) + booksRepository := mocks.NewMockBooksRepository(controller) + logger := zap.NewNop() + lib := New(logger, booksRepository, authorsRepository) + + return setup{ + booksRepositoryMock: booksRepository, + authorsRepositoryMock: authorsRepository, + booksUseCase: lib, + authorsUseCase: lib, + } +} diff --git a/internal/usecase/repository/inmemory.go b/internal/usecase/repository/inmemory.go deleted file mode 100644 index 50a4378..0000000 --- a/internal/usecase/repository/inmemory.go +++ /dev/null @@ -1 +0,0 @@ -package repository diff --git a/internal/usecase/repository/interfaces.go b/internal/usecase/repository/interfaces.go index 50a4378..a7493e8 100644 --- a/internal/usecase/repository/interfaces.go +++ b/internal/usecase/repository/interfaces.go @@ -1 +1,23 @@ package repository + +import ( + "context" + + "github.com/google/uuid" + "github.com/project/library/internal/entity" +) + +//go:generate ../../../bin/mockgen -source=interfaces.go -destination=../../../mocks-generated/repositories_mock.go -package=mocks_generated + +type BooksRepository interface { + AddBook(ctx context.Context, book entity.Book) (entity.Book, error) + GetBook(ctx context.Context, bookID uuid.UUID) (entity.Book, error) + UpdateBook(ctx context.Context, bookID uuid.UUID, name string, authorIDs uuid.UUIDs) (entity.Book, error) + GetAuthorBooks(ctx context.Context, authorID uuid.UUID) ([]entity.Book, error) +} + +type AuthorsRepository interface { + AddAuthor(ctx context.Context, author entity.Author) (entity.Author, error) + GetAuthor(ctx context.Context, authorID uuid.UUID) (entity.Author, error) + UpdateAuthor(ctx context.Context, authorID uuid.UUID, name string) (entity.Author, error) +} diff --git a/internal/usecase/repository/postgres.go b/internal/usecase/repository/postgres.go new file mode 100644 index 0000000..b1503c9 --- /dev/null +++ b/internal/usecase/repository/postgres.go @@ -0,0 +1,328 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "go.uber.org/zap" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/project/library/internal/entity" +) + +var _ BooksRepository = (*postgresRepository)(nil) +var _ AuthorsRepository = (*postgresRepository)(nil) + +type postgresRepository struct { + db *pgxpool.Pool + logger *zap.Logger +} + +func New(db *pgxpool.Pool, logger *zap.Logger) *postgresRepository { + return &postgresRepository{ + db: db, + logger: logger, + } +} + +func (repo *postgresRepository) AddAuthor(ctx context.Context, author entity.Author) (entity.Author, error) { + repo.logger.Info("AddAuthor repo: started") + defer repo.logger.Info("AddAuthor repo: finished") + + const query = ` + INSERT INTO author (name) + VALUES ($1) + RETURNING id + ` + err := repo.db.QueryRow(ctx, query, author.Name).Scan(&author.ID) + if err != nil { + return entity.Author{}, fmt.Errorf("add author query failed: %w", err) + } + + return author, nil +} + +func (repo *postgresRepository) GetAuthor(ctx context.Context, authorID uuid.UUID) (entity.Author, error) { + repo.logger.Info("GetAuthor repo: started") + defer repo.logger.Info("GetAuthor repo: finished") + + const query = ` + SELECT id, name + FROM author + WHERE id = $1 + ` + var author entity.Author + err := repo.db.QueryRow(ctx, query, authorID).Scan(&author.ID, &author.Name) + + if errors.Is(err, pgx.ErrNoRows) { + return entity.Author{}, entity.ErrAuthorNotFound + } + + if err != nil { + return entity.Author{}, fmt.Errorf("get author query failed: %w", err) + } + + return author, nil +} + +func (repo *postgresRepository) UpdateAuthor(ctx context.Context, authorID uuid.UUID, name string) (entity.Author, error) { + repo.logger.Info("UpdateAuthor repo: started") + defer repo.logger.Info("UpdateAuthor repo: finished") + + return withTransaction(ctx, repo.db, repo.logger, func(tx pgx.Tx) (entity.Author, error) { + const query = ` + UPDATE author + SET name = $1 + WHERE id = $2 + RETURNING id, name + ` + var author entity.Author + err := tx.QueryRow(ctx, query, name, authorID).Scan(&author.ID, &author.Name) + if errors.Is(err, pgx.ErrNoRows) { + return entity.Author{}, entity.ErrAuthorNotFound + } + + if err != nil { + return entity.Author{}, fmt.Errorf("update author query failed: %w", err) + } + + return author, nil + }) +} + +func (repo *postgresRepository) AddBook(ctx context.Context, book entity.Book) (entity.Book, error) { + repo.logger.Info("AddBook repo: started") + defer repo.logger.Info("AddBook repo: finished") + + return withTransaction(ctx, repo.db, repo.logger, func(tx pgx.Tx) (entity.Book, error) { + const queryAddBook = ` + INSERT INTO book (name) + VALUES ($1) + RETURNING id, name, created_at , updated_at + ` + + var addedBook entity.Book + err := tx.QueryRow(ctx, queryAddBook, book.Name).Scan(&addedBook.ID, &addedBook.Name, &addedBook.CreatedAt, &addedBook.UpdatedAt) + + if err != nil { + return entity.Book{}, fmt.Errorf("add book query failed: %w", err) + } + + if err := insertAuthorsBatch(ctx, tx, addedBook.ID, book.AuthorIDs); err != nil { + return entity.Book{}, fmt.Errorf("add book's authors query failed: %w", err) + } + + addedBook.AuthorIDs = book.AuthorIDs + return addedBook, nil + }) +} + +func (repo *postgresRepository) GetBook(ctx context.Context, bookID uuid.UUID) (entity.Book, error) { + repo.logger.Info("GetBook repo: started") + defer repo.logger.Info("GetBook repo: finished") + + const query = ` + SELECT + id, + name, + created_at, + updated_at, + ARRAY( + SELECT author_id + FROM author_book + WHERE book_id = $1 + ) AS author_ids + FROM book + WHERE id = $1 + ` + + var book entity.Book + err := repo.db.QueryRow(ctx, query, bookID).Scan( + &book.ID, + &book.Name, + &book.CreatedAt, + &book.UpdatedAt, + &book.AuthorIDs, + ) + + if errors.Is(err, pgx.ErrNoRows) { + return entity.Book{}, entity.ErrBookNotFound + } + if err != nil { + return entity.Book{}, fmt.Errorf("get book query failed: %w", err) + } + + return book, nil +} + +func (repo *postgresRepository) UpdateBook(ctx context.Context, bookID uuid.UUID, name string, authorIDs uuid.UUIDs) (entity.Book, error) { + repo.logger.Info("UpdateBook repo: started") + defer repo.logger.Info("UpdateBook repo: finished") + + return withTransaction(ctx, repo.db, repo.logger, func(tx pgx.Tx) (entity.Book, error) { + const queryUpdateBook = ` + UPDATE book + SET name = $1 + WHERE id = $2 + RETURNING id, name, created_at, updated_at; + ` + + var book entity.Book + err := tx.QueryRow(ctx, queryUpdateBook, name, bookID).Scan(&book.ID, &book.Name, &book.CreatedAt, &book.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return entity.Book{}, entity.ErrBookNotFound + } + if err != nil { + return entity.Book{}, fmt.Errorf("update book query failed: %w", err) + } + + const queryDeleteBookAuthors = ` + DELETE FROM author_book + WHERE book_id = $1; + ` + + _, err = tx.Exec(ctx, queryDeleteBookAuthors, bookID) + if err != nil { + return entity.Book{}, fmt.Errorf("delete previous book's author query failed: %w", err) + } + + if err := insertAuthorsBatch(ctx, tx, bookID, authorIDs); err != nil { + return entity.Book{}, fmt.Errorf("add book's authors query failed: %w", err) + } + + book.AuthorIDs = authorIDs + return book, nil + }) +} + +func (repo *postgresRepository) GetAuthorBooks(ctx context.Context, authorID uuid.UUID) ([]entity.Book, error) { + repo.logger.Info("GetAuthorBooks repo: started") + defer repo.logger.Info("GetAuthorBooks repo: finished") + + const query = ` + SELECT + book.id, + book.name, + book.created_at, + book.updated_at, + ARRAY( + SELECT author_book.author_id + FROM author_book + WHERE author_book.book_id = book.id + ) AS author_ids + FROM book + INNER JOIN author_book ON book.id = author_book.book_id + WHERE author_book.author_id = $1 + ` + + rows, err := repo.db.Query(ctx, query, authorID) + if err != nil { + return nil, fmt.Errorf("failed to query author books: %w", err) + } + defer rows.Close() + + var books []entity.Book + for rows.Next() { + var book entity.Book + var authorIDs []uuid.UUID + + err := rows.Scan( + &book.ID, + &book.Name, + &book.CreatedAt, + &book.UpdatedAt, + &authorIDs, + ) + + if err != nil { + return nil, fmt.Errorf("failed to scan book row: %w", err) + } + + book.AuthorIDs = authorIDs + books = append(books, book) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration error: %w", err) + } + + return books, nil +} + +func isForeignKeyViolation(err error) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23503" + } + return false +} + +func withTransaction[T any](ctx context.Context, db *pgxpool.Pool, logger *zap.Logger, fn func(pgx.Tx) (T, error)) (T, error) { + logger.Info("Transaction started") + defer logger.Info("Transaction finished") + + var zero T + tx, err := db.Begin(ctx) + if err != nil { + return zero, err + } + defer func(tx pgx.Tx, ctx context.Context) { + err := tx.Rollback(ctx) + if err != nil { + logger.Error("failed to rollback transaction", zap.Error(err)) + } + logger.Info("Transaction rolled back") + }(tx, ctx) + + result, err := fn(tx) + if err != nil { + return zero, err + } + + if err = tx.Commit(ctx); err != nil { + logger.Info("Not commited", zap.Error(err)) + return zero, err + } + + logger.Info("Commited") + + return result, nil +} + +func insertAuthorsBatch(ctx context.Context, tx pgx.Tx, bookID uuid.UUID, authorIDs []uuid.UUID) error { + if len(authorIDs) == 0 { + return nil + } + + rows := make([][]interface{}, 0, len(authorIDs)) + for _, authorID := range authorIDs { + rows = append(rows, []interface{}{authorID, bookID}) + } + + tableName := pgx.Identifier{"author_book"} + columns := []string{"author_id", "book_id"} + + _, err := tx.CopyFrom( + ctx, + tableName, + columns, + pgx.CopyFromRows(rows), + ) + + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if isForeignKeyViolation(pgErr) { + return entity.ErrAuthorNotFound + } + } + return fmt.Errorf("copy from failed: %w", err) + } + + return nil +} diff --git a/mocks-custom/stream_mock.go b/mocks-custom/stream_mock.go new file mode 100644 index 0000000..579d088 --- /dev/null +++ b/mocks-custom/stream_mock.go @@ -0,0 +1,81 @@ +package mocks_custom + +import ( + context "context" + "sync" + + "github.com/project/library/generated/api/library" + "google.golang.org/grpc/metadata" +) + +type MockLibraryGetAuthorBooksServer struct { + SentBooks []*library.Book + SendError error + CancelCtx context.CancelFunc + mu sync.Mutex +} + +func NewMockLibraryGetAuthorBooksServer(cancel context.CancelFunc, sendError error) *MockLibraryGetAuthorBooksServer { + return &MockLibraryGetAuthorBooksServer{ + SendError: sendError, + CancelCtx: cancel, + } +} + +// RecvMsg implements library.Library_GetAuthorBooksServer. +func (*MockLibraryGetAuthorBooksServer) RecvMsg(_ any) error { + panic("unimplemented") +} + +// SendHeader implements library.Library_GetAuthorBooksServer. +func (m *MockLibraryGetAuthorBooksServer) SendHeader(metadata.MD) error { + panic("unimplemented") +} + +// SendMsg implements library.Library_GetAuthorBooksServer. +func (*MockLibraryGetAuthorBooksServer) SendMsg(_ any) error { + panic("unimplemented") +} + +// SetHeader implements library.Library_GetAuthorBooksServer. +func (m *MockLibraryGetAuthorBooksServer) SetHeader(metadata.MD) error { + panic("unimplemented") +} + +// SetTrailer implements library.Library_GetAuthorBooksServer. +func (m *MockLibraryGetAuthorBooksServer) SetTrailer(metadata.MD) { + panic("unimplemented") +} + +// Send сохраняет отправленные книги и возвращает заданную ошибку +func (m *MockLibraryGetAuthorBooksServer) Send(book *library.Book) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.SendError == nil { + m.SentBooks = append(m.SentBooks, book) + } + return m.SendError +} + +// Context возвращает контекст мока +func (m *MockLibraryGetAuthorBooksServer) Context() context.Context { + return context.Background() +} + +// Reset очищает состояние мока +func (m *MockLibraryGetAuthorBooksServer) Reset() { + panic("unimplemented") +} + +// WithCancel добавляет возможность отмены контекста +func (m *MockLibraryGetAuthorBooksServer) WithCancel() *MockLibraryGetAuthorBooksServer { + panic("unimplemented") +} + +// Cancel вызывает отмену контекста +func (m *MockLibraryGetAuthorBooksServer) Cancel() { + if m.CancelCtx != nil { + m.CancelCtx() + } +}