diff --git a/go.mod b/go.mod index 3eab0bf..7ab443d 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/kelseyhightower/envconfig v1.4.0 github.com/lib/pq v1.10.7 github.com/pkg/errors v0.9.1 - github.com/rog-golang-buddies/api_hub_common v0.1.5 + github.com/rog-golang-buddies/api_hub_common v0.1.6 github.com/stretchr/testify v1.8.0 github.com/testcontainers/testcontainers-go v0.14.0 github.com/wagslane/go-rabbitmq v0.10.0 diff --git a/go.sum b/go.sum index de56262..5b24b9b 100644 --- a/go.sum +++ b/go.sum @@ -1040,8 +1040,8 @@ github.com/rabbitmq/amqp091-go v1.4.0 h1:T2G+J9W9OY4p64Di23J6yH7tOkMocgnESvYeBju github.com/rabbitmq/amqp091-go v1.4.0/go.mod h1:JsV0ofX5f1nwOGafb8L5rBItt9GyhfQfcJj+oyz0dGg= github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rog-golang-buddies/api_hub_common v0.1.5 h1:GrgO5Iqh/CFhSMwWWTSIx9lcLz/340zt5R0ll7Ik7Yw= -github.com/rog-golang-buddies/api_hub_common v0.1.5/go.mod h1:lK3L/e21s8rYwOZYrSrnP3yaAST3fgKlh00WW6igI0s= +github.com/rog-golang-buddies/api_hub_common v0.1.6 h1:dnfqnyra0vXMZNT6pgNRUfyG25HZ3U/dLvMAS4MiYXM= +github.com/rog-golang-buddies/api_hub_common v0.1.6/go.mod h1:lK3L/e21s8rYwOZYrSrnP3yaAST3fgKlh00WW6igI0s= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= diff --git a/internal/apispecdoc/apiMethod.go b/internal/apispecdoc/apiMethod.go new file mode 100644 index 0000000..84476e9 --- /dev/null +++ b/internal/apispecdoc/apiMethod.go @@ -0,0 +1,15 @@ +package apispecdoc + +type ApiMethod struct { + ID uint `gorm:"primaryKey"` + Path string + Name string + Description string + Type string + Parameters string + Servers []*Server + RequestBody string + ExternalDoc *ExternalDoc + GroupID *uint + ApiSpecDocID *uint +} diff --git a/internal/apispecdoc/apiSpecDoc.go b/internal/apispecdoc/apiSpecDoc.go new file mode 100644 index 0000000..2b46ced --- /dev/null +++ b/internal/apispecdoc/apiSpecDoc.go @@ -0,0 +1,19 @@ +package apispecdoc + +import ( + "time" + + "gorm.io/gorm" +) + +type ApiSpecDoc struct { + gorm.Model + Title string + Description string + Type string + Groups []*Group + ApiMethods []*ApiMethod + Md5sum string + Url string + FetchedAt time.Time +} diff --git a/internal/apispecdoc/externalDoc.go b/internal/apispecdoc/externalDoc.go new file mode 100644 index 0000000..adb4037 --- /dev/null +++ b/internal/apispecdoc/externalDoc.go @@ -0,0 +1,8 @@ +package apispecdoc + +type ExternalDoc struct { + ID int `gorm:"primaryKey"` + Description string + URL string + ApiMethodID *uint +} diff --git a/internal/apispecdoc/group.go b/internal/apispecdoc/group.go new file mode 100644 index 0000000..2feec4a --- /dev/null +++ b/internal/apispecdoc/group.go @@ -0,0 +1,9 @@ +package apispecdoc + +type Group struct { + ID uint `gorm:"primaryKey"` + Name string + Description string + ApiSpecDocID *uint + ApiMethods []*ApiMethod +} diff --git a/internal/apispecdoc/mock/repository.go b/internal/apispecdoc/mock/repository.go new file mode 100644 index 0000000..107d646 --- /dev/null +++ b/internal/apispecdoc/mock/repository.go @@ -0,0 +1,140 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: repository.go + +// Package apispecdoc is a generated GoMock package. +package apispecdoc + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + apispecdoc "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/apispecdoc" + dto "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/dto" +) + +// MockAsdRepository is a mock of AsdRepository interface. +type MockAsdRepository struct { + ctrl *gomock.Controller + recorder *MockAsdRepositoryMockRecorder +} + +// MockAsdRepositoryMockRecorder is the mock recorder for MockAsdRepository. +type MockAsdRepositoryMockRecorder struct { + mock *MockAsdRepository +} + +// NewMockAsdRepository creates a new mock instance. +func NewMockAsdRepository(ctrl *gomock.Controller) *MockAsdRepository { + mock := &MockAsdRepository{ctrl: ctrl} + mock.recorder = &MockAsdRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAsdRepository) EXPECT() *MockAsdRepositoryMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockAsdRepository) Delete(ctx context.Context, asd *apispecdoc.ApiSpecDoc) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, asd) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockAsdRepositoryMockRecorder) Delete(ctx, asd interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockAsdRepository)(nil).Delete), ctx, asd) +} + +// FindByHash mocks base method. +func (m *MockAsdRepository) FindByHash(ctx context.Context, hash string) (*apispecdoc.ApiSpecDoc, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByHash", ctx, hash) + ret0, _ := ret[0].(*apispecdoc.ApiSpecDoc) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByHash indicates an expected call of FindByHash. +func (mr *MockAsdRepositoryMockRecorder) FindByHash(ctx, hash interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByHash", reflect.TypeOf((*MockAsdRepository)(nil).FindByHash), ctx, hash) +} + +// FindById mocks base method. +func (m *MockAsdRepository) FindById(ctx context.Context, id uint) (*apispecdoc.ApiSpecDoc, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindById", ctx, id) + ret0, _ := ret[0].(*apispecdoc.ApiSpecDoc) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindById indicates an expected call of FindById. +func (mr *MockAsdRepositoryMockRecorder) FindById(ctx, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindById", reflect.TypeOf((*MockAsdRepository)(nil).FindById), ctx, id) +} + +// FindByUrl mocks base method. +func (m *MockAsdRepository) FindByUrl(ctx context.Context, url string) (*apispecdoc.ApiSpecDoc, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByUrl", ctx, url) + ret0, _ := ret[0].(*apispecdoc.ApiSpecDoc) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByUrl indicates an expected call of FindByUrl. +func (mr *MockAsdRepositoryMockRecorder) FindByUrl(ctx, url interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByUrl", reflect.TypeOf((*MockAsdRepository)(nil).FindByUrl), ctx, url) +} + +// Save mocks base method. +func (m *MockAsdRepository) Save(ctx context.Context, asd *apispecdoc.ApiSpecDoc) (uint, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Save", ctx, asd) + ret0, _ := ret[0].(uint) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Save indicates an expected call of Save. +func (mr *MockAsdRepositoryMockRecorder) Save(ctx, asd interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockAsdRepository)(nil).Save), ctx, asd) +} + +// SearchShort mocks base method. +func (m *MockAsdRepository) SearchShort(ctx context.Context, search string, page dto.PageRequest) (apispecdoc.AsdPage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchShort", ctx, search, page) + ret0, _ := ret[0].(apispecdoc.AsdPage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchShort indicates an expected call of SearchShort. +func (mr *MockAsdRepositoryMockRecorder) SearchShort(ctx, search, page interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchShort", reflect.TypeOf((*MockAsdRepository)(nil).SearchShort), ctx, search, page) +} + +// Update mocks base method. +func (m *MockAsdRepository) Update(ctx context.Context, asd *apispecdoc.ApiSpecDoc) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, asd) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockAsdRepositoryMockRecorder) Update(ctx, asd interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockAsdRepository)(nil).Update), ctx, asd) +} diff --git a/internal/apispecdoc/mock/service.go b/internal/apispecdoc/mock/service.go new file mode 100644 index 0000000..0575154 --- /dev/null +++ b/internal/apispecdoc/mock/service.go @@ -0,0 +1,82 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: service.go + +// Package apispecdoc is a generated GoMock package. +package apispecdoc + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + apispecdoc "github.com/rog-golang-buddies/api_hub_common/apispecdoc" + apispecproto "github.com/rog-golang-buddies/api_hub_common/apispecproto" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockService) Get(arg0 context.Context, arg1 *apispecproto.GetRequest) (*apispecproto.GetResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(*apispecproto.GetResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockServiceMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockService)(nil).Get), arg0, arg1) +} + +// Save mocks base method. +func (m *MockService) Save(arg0 context.Context, arg1 *apispecdoc.ApiSpecDoc) (uint, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Save", arg0, arg1) + ret0, _ := ret[0].(uint) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Save indicates an expected call of Save. +func (mr *MockServiceMockRecorder) Save(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockService)(nil).Save), arg0, arg1) +} + +// Search mocks base method. +func (m *MockService) Search(arg0 context.Context, arg1 *apispecproto.SearchRequest) (*apispecproto.SearchResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Search", arg0, arg1) + ret0, _ := ret[0].(*apispecproto.SearchResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Search indicates an expected call of Search. +func (mr *MockServiceMockRecorder) Search(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockService)(nil).Search), arg0, arg1) +} diff --git a/internal/apispecdoc/repository.go b/internal/apispecdoc/repository.go new file mode 100644 index 0000000..f974196 --- /dev/null +++ b/internal/apispecdoc/repository.go @@ -0,0 +1,31 @@ +package apispecdoc + +import ( + "context" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/dto" +) + +// AsdPage here represents a fixed type. +// It's just workaround for gomock i.e. it can't generate a mock of interface with generics used in it. +// Issue closed https://github.com/golang/mock/issues/621 - awaiting for gomock version 1.7.0. +// TODO delete on gomock 1.7.0 version released +type AsdPage = dto.Page[*ApiSpecDoc] + +//go:generate mockgen -source=repository.go -destination=./mock/repository.go -package=apispecdoc +type AsdRepository interface { + //Save saves new ApiSpecDoc entity to the database + Save(ctx context.Context, asd *ApiSpecDoc) (uint, error) + //Delete ApiSpecDoc soft, i.e. update deleted_at field and prevent the record from appearing in the requests + Delete(ctx context.Context, asd *ApiSpecDoc) error + //Update ApiSpecDoc by replacing all old nested elements with new ones + Update(ctx context.Context, asd *ApiSpecDoc) error + //FindById returns full ApiSpecDoc with all nested elements or nil if a such record does not exist + FindById(ctx context.Context, id uint) (*ApiSpecDoc, error) + //FindByHash returns ApiSpecDoc without nested elements or nil if nothing is found + FindByHash(ctx context.Context, hash string) (*ApiSpecDoc, error) + //FindByUrl returns ApiSpecDoc without nested elements or nil if nothing is found + FindByUrl(ctx context.Context, url string) (*ApiSpecDoc, error) + //SearchShort returns a slice of ApiSpecDoc without nested elements that match search string + //The search goes by title and url fields + SearchShort(ctx context.Context, search string, page dto.PageRequest) (AsdPage, error) +} diff --git a/internal/apispecdoc/server.go b/internal/apispecdoc/server.go new file mode 100644 index 0000000..c1d38d6 --- /dev/null +++ b/internal/apispecdoc/server.go @@ -0,0 +1,8 @@ +package apispecdoc + +type Server struct { + ID int `gorm:"primaryKey"` + URL string + Description string + ApiMethodID *uint +} diff --git a/internal/apispecdoc/service.go b/internal/apispecdoc/service.go new file mode 100644 index 0000000..471503e --- /dev/null +++ b/internal/apispecdoc/service.go @@ -0,0 +1,14 @@ +package apispecdoc + +import ( + "context" + "github.com/rog-golang-buddies/api_hub_common/apispecdoc" + "github.com/rog-golang-buddies/api_hub_common/apispecproto" +) + +//go:generate mockgen -source=service.go -destination=./mock/service.go -package=apispecdoc +type Service interface { + Search(context.Context, *apispecproto.SearchRequest) (*apispecproto.SearchResponse, error) + Get(context.Context, *apispecproto.GetRequest) (*apispecproto.GetResponse, error) + Save(context.Context, *apispecdoc.ApiSpecDoc) (uint, error) +} diff --git a/internal/app.go b/internal/app.go index fe9f4ce..eef02a7 100644 --- a/internal/app.go +++ b/internal/app.go @@ -10,6 +10,10 @@ import ( "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/queue" "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/queue/handler" "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/queue/publisher" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/repository" + + //"github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/repository" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/service" ) func Start() int { @@ -26,11 +30,13 @@ func Start() int { fmt.Println("error creating logger: ", err) return 1 } - _, err = db.ConnectAndMigrate(log, &conf.DB) + DB, err := db.ConnectAndMigrate(log, &conf.DB) if err != nil { log.Error("error while db setup: ", err) return 1 } + asdRepo := repository.NewASDRepository(DB) + asdServ := service.NewService(log, asdRepo, &conf.Page) //initialize publisher connection to the queue //this library assumes using one publisher and one consumer per application @@ -49,7 +55,7 @@ func Start() int { } defer queue.CloseConsumer(consumer, log) - handl := handler.NewApiSpecDocHandler(pub, conf.Queue, log) + handl := handler.NewApiSpecDocHandler(pub, conf.Queue, log, asdServ) listener := queue.NewListener() err = listener.Start(ctx, consumer, &conf.Queue, handl) if err != nil { @@ -63,7 +69,7 @@ func Start() int { log.Error("error creating grpc listener: ", err) return 1 } - asdSrv := grpc.NewASDServer(log) + asdSrv := grpc.NewASDServer(log, asdServ) errCh := grpc.StartServer(ctx, log, asdSrv, lst) <-errCh diff --git a/internal/config/PageConfig.go b/internal/config/PageConfig.go new file mode 100644 index 0000000..b2fead5 --- /dev/null +++ b/internal/config/PageConfig.go @@ -0,0 +1,5 @@ +package config + +type PageConfig struct { + MinPerPage int `default:"3"` +} diff --git a/internal/config/application.go b/internal/config/application.go index d73c3d3..3c6d3f4 100644 --- a/internal/config/application.go +++ b/internal/config/application.go @@ -10,6 +10,7 @@ type ApplicationConfig struct { Queue QueueConfig GRPC GRPCConfig DB DbConfig + Page PageConfig } // ReadConfig reads configuration from the environment and populates the structure with it diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 4e74bb7..3b0a821 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -15,7 +15,7 @@ import ( ) //go:embed migrations/*.sql -var fs embed.FS +var FS embed.FS func Migrate(db *sql.DB, fsDriver source.Driver, conf *config.DbConfig) error { driver, err := postgres.WithInstance(db, &postgres.Config{}) @@ -44,7 +44,7 @@ func ConnectAndMigrate(log logger.Logger, conf *config.DbConfig) (*gorm.DB, erro if err != nil { return nil, err } - fsDriver, err := iofs.New(fs, "migrations") + fsDriver, err := iofs.New(FS, "migrations") if err != nil { return nil, err } diff --git a/internal/db/migrations/0_init_empty.down.sql b/internal/db/migrations/0_init_empty.down.sql deleted file mode 100644 index e69de29..0000000 diff --git a/internal/db/migrations/0_init_empty.up.sql b/internal/db/migrations/0_init_empty.up.sql deleted file mode 100644 index e69de29..0000000 diff --git a/internal/db/migrations/1_initial_asd.down.sql b/internal/db/migrations/1_initial_asd.down.sql new file mode 100644 index 0000000..98ac1f0 --- /dev/null +++ b/internal/db/migrations/1_initial_asd.down.sql @@ -0,0 +1,9 @@ +drop table servers; + +drop table external_docs; + +drop table api_methods; + +drop table groups; + +drop table api_spec_docs; diff --git a/internal/db/migrations/1_initial_asd.up.sql b/internal/db/migrations/1_initial_asd.up.sql new file mode 100644 index 0000000..1277661 --- /dev/null +++ b/internal/db/migrations/1_initial_asd.up.sql @@ -0,0 +1,54 @@ +create table api_spec_docs +( + id bigserial primary key, + title text, + description text, + type text, + md5sum text unique, + url text unique, + created_at timestamp with time zone, + updated_at timestamp with time zone, + deleted_at timestamp with time zone, + fetched_at timestamp with time zone +); + +create index idx_api_spec_docs_deleted_at + on api_spec_docs (deleted_at); + +create table groups +( + id bigserial primary key, + name text, + description text, + api_spec_doc_id int references api_spec_docs (id) on delete cascade on update cascade +); + +create table api_methods +( + id bigserial primary key, + path text, + name text, + description text, + type text, + parameters text, + request_body text, + api_spec_doc_id int references api_spec_docs (id) on delete cascade on update cascade, + group_id int references groups (id) on delete cascade on update cascade + check (num_nonnulls(api_spec_doc_id, group_id) = 1) +); + +create table external_docs +( + id bigserial primary key, + description text, + url text, + api_method_id int unique references api_methods (id) on delete cascade on update cascade +); + +create table servers +( + id bigserial primary key, + url text, + description text, + api_method_id int references api_methods (id) on delete cascade on update cascade +); diff --git a/internal/dto/page.go b/internal/dto/page.go new file mode 100644 index 0000000..4bbce2a --- /dev/null +++ b/internal/dto/page.go @@ -0,0 +1,9 @@ +package dto + +// Page represents consistent result from the repository with page data +type Page[T any] struct { + Data []T + Page int + PerPage int + Total int +} diff --git a/internal/dto/pageRequest.go b/internal/dto/pageRequest.go new file mode 100644 index 0000000..51320ba --- /dev/null +++ b/internal/dto/pageRequest.go @@ -0,0 +1,6 @@ +package dto + +type PageRequest struct { + PerPage int + Page int +} diff --git a/internal/grpc/server.go b/internal/grpc/server.go index bf2dd6b..c288ab7 100644 --- a/internal/grpc/server.go +++ b/internal/grpc/server.go @@ -2,8 +2,8 @@ package grpc import ( "context" - "errors" "fmt" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/apispecdoc" "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/config" "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/logger" "github.com/rog-golang-buddies/api_hub_common/apispecproto" @@ -13,24 +13,24 @@ import ( type ApiSpecDocServerImpl struct { apispecproto.UnimplementedApiSpecDocServer - log logger.Logger + service apispecdoc.Service + log logger.Logger } func (asds *ApiSpecDocServerImpl) Search(ctx context.Context, req *apispecproto.SearchRequest) (*apispecproto.SearchResponse, error) { - //TODO implement me asds.log.Info("Search: ", req) - return nil, errors.New("not implemented") + return asds.service.Search(ctx, req) } func (asds *ApiSpecDocServerImpl) Get(ctx context.Context, req *apispecproto.GetRequest) (*apispecproto.GetResponse, error) { - //TODO implement me asds.log.Info("Get: ", req) - return nil, errors.New("not implemented") + return asds.service.Get(ctx, req) } -func NewASDServer(log logger.Logger) apispecproto.ApiSpecDocServer { +func NewASDServer(log logger.Logger, service apispecdoc.Service) apispecproto.ApiSpecDocServer { return &ApiSpecDocServerImpl{ - log: log, + log: log, + service: service, } } diff --git a/internal/grpc/server_test.go b/internal/grpc/server_test.go index cfec7c7..d441ac4 100644 --- a/internal/grpc/server_test.go +++ b/internal/grpc/server_test.go @@ -3,8 +3,10 @@ package grpc import ( "context" "github.com/golang/mock/gomock" + asdmock "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/apispecdoc/mock" "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/config" mock_logger "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/logger/mocks" + "github.com/rog-golang-buddies/api_hub_common/apispecproto" "github.com/stretchr/testify/assert" "google.golang.org/grpc" "testing" @@ -14,8 +16,9 @@ import ( func TestServerStartsAndStops(t *testing.T) { ctrl := gomock.NewController(t) log := mock_logger.NewMockLogger(ctrl) + asdService := asdmock.NewMockService(ctrl) - server := NewASDServer(log) + server := NewASDServer(log, asdService) assert.NotNil(t, server) conf, err := config.ReadConfig() @@ -53,8 +56,9 @@ func TestServerStopsOnContextCancel(t *testing.T) { ctrl := gomock.NewController(t) log := mock_logger.NewMockLogger(ctrl) log.EXPECT().Info("context done, stopping grpc server...") + asdService := asdmock.NewMockService(ctrl) - server := NewASDServer(log) + server := NewASDServer(log, asdService) assert.NotNil(t, server) conf, err := config.ReadConfig() @@ -87,3 +91,46 @@ func TestServerStopsOnContextCancel(t *testing.T) { t.Error("error channel wasn't closed on server shutdown") } } + +func TestApiSpecDocServerImpl_Get(t *testing.T) { + ctrl := gomock.NewController(t) + asdService := asdmock.NewMockService(ctrl) + log := mock_logger.NewMockLogger(ctrl) + log.EXPECT().Info(gomock.Any()).Times(1) + + server := NewASDServer(log, asdService) + ctx := context.Background() + var id uint32 = 54 + expResp := &apispecproto.GetResponse{ApiSpecDoc: &apispecproto.FullASD{Id: id}} + expReq := &apispecproto.GetRequest{Id: id} + asdService.EXPECT().Get(ctx, expReq).Return(expResp, nil) + assert.NotNil(t, server) + resp, err := server.Get(ctx, expReq) + assert.Nil(t, err) + assert.NotNil(t, resp) + assert.NotNil(t, resp.ApiSpecDoc) + assert.Equal(t, expResp, resp) +} + +func TestApiSpecDocServerImpl_Search(t *testing.T) { + ctrl := gomock.NewController(t) + asdService := asdmock.NewMockService(ctrl) + log := mock_logger.NewMockLogger(ctrl) + log.EXPECT().Info(gomock.Any()).Times(1) + server := NewASDServer(log, asdService) + ctx := context.Background() + expResp := &apispecproto.SearchResponse{ShortSpecDocs: []*apispecproto.ShortASD{ + { + Id: 54, + Name: "test search name", + }, + }} + expReq := &apispecproto.SearchRequest{Search: "test search"} + asdService.EXPECT().Search(ctx, expReq).Return(expResp, nil) + assert.NotNil(t, server) + resp, err := server.Search(ctx, expReq) + assert.Nil(t, err) + assert.NotNil(t, resp) + assert.NotNil(t, resp.ShortSpecDocs) + assert.Equal(t, expResp, resp) +} diff --git a/internal/queue/handler/apispec.go b/internal/queue/handler/apispec.go index 6d2b7c8..b9268ce 100644 --- a/internal/queue/handler/apispec.go +++ b/internal/queue/handler/apispec.go @@ -3,7 +3,7 @@ package handler import ( "context" "encoding/json" - + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/apispecdoc" "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/config" "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/dto" "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/logger" @@ -13,13 +13,14 @@ import ( ) type ApiSpecDocHandler struct { - publisher publisher.Publisher - config config.QueueConfig - log logger.Logger + publisher publisher.Publisher + config config.QueueConfig + asdService apispecdoc.Service + log logger.Logger } func (asdh *ApiSpecDocHandler) Handle(ctx context.Context, delivery rabbitmq.Delivery) rabbitmq.Action { - asdh.log.Infof("consumed: %v", string(delivery.Body)) + //get link to API from queue and unmarshal json response to req var req dto.ScrapingResult err := json.Unmarshal(delivery.Body, &req) @@ -35,18 +36,43 @@ func (asdh *ApiSpecDocHandler) Handle(ctx context.Context, delivery rabbitmq.Del } return rabbitmq.NackDiscard } + if req.ApiSpecDoc == nil { + if req.IsNotifyUser { + asdh.notifyUser(&delivery, &dto.ProcessingError{ + Message: "nil body request received", + }) + } + return rabbitmq.NackDiscard + } + asdh.log.Infof("consumed ASD: name: %s; md5: %s", req.ApiSpecDoc.Title, req.ApiSpecDoc.Md5Sum) - if req.IsNotifyUser { - err = asdh.publish(&delivery, dto.NewUserNotification(nil), asdh.config.NotificationQueue) - if err != nil { - asdh.log.Error("error while notifying user") - //don't discard this message because it was published to the storage service successfully + _, err = asdh.asdService.Save(ctx, req.ApiSpecDoc) + if err != nil { + asdh.log.Error("error while saving ASD: ", err) + if req.IsNotifyUser { + asdh.notifyUser(&delivery, &dto.ProcessingError{ + Cause: err.Error(), + Message: "error while saving", + }) } + return rabbitmq.NackDiscard + } + + if req.IsNotifyUser { + asdh.notifyUser(&delivery, nil) } - asdh.log.Info("url scraped successfully") + asdh.log.Info("API specification document saved/updated successfully") return rabbitmq.Ack } +func (asdh *ApiSpecDocHandler) notifyUser(delivery *rabbitmq.Delivery, procErr *dto.ProcessingError) { + err := asdh.publish(delivery, dto.NewUserNotification(procErr), asdh.config.NotificationQueue) + if err != nil { + asdh.log.Error("error while notifying user") + //don't discard this message because it was published to the storage service successfully + } +} + func (asdh *ApiSpecDocHandler) publish(delivery *rabbitmq.Delivery, message any, queue string) error { content, err := json.Marshal(message) if err != nil { @@ -61,12 +87,12 @@ func (asdh *ApiSpecDocHandler) publish(delivery *rabbitmq.Delivery, message any, ) } -func NewApiSpecDocHandler(publisher publisher.Publisher, - config config.QueueConfig, - log logger.Logger) Handler { +func NewApiSpecDocHandler(publisher publisher.Publisher, config config.QueueConfig, + log logger.Logger, asdService apispecdoc.Service) Handler { return &ApiSpecDocHandler{ - publisher: publisher, - config: config, - log: log, + publisher: publisher, + config: config, + asdService: asdService, + log: log, } } diff --git a/internal/queue/mocks/consumer.go b/internal/queue/mocks/consumer.go new file mode 100644 index 0000000..22b8d06 --- /dev/null +++ b/internal/queue/mocks/consumer.go @@ -0,0 +1,68 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: consumer.go + +// Package mock_queue is a generated GoMock package. +package mock_queue + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + rabbitmq "github.com/wagslane/go-rabbitmq" +) + +// MockConsumer is a mock of Consumer interface. +type MockConsumer struct { + ctrl *gomock.Controller + recorder *MockConsumerMockRecorder +} + +// MockConsumerMockRecorder is the mock recorder for MockConsumer. +type MockConsumerMockRecorder struct { + mock *MockConsumer +} + +// NewMockConsumer creates a new mock instance. +func NewMockConsumer(ctrl *gomock.Controller) *MockConsumer { + mock := &MockConsumer{ctrl: ctrl} + mock.recorder = &MockConsumerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockConsumer) EXPECT() *MockConsumerMockRecorder { + return m.recorder +} + +// Close mocks base method. +func (m *MockConsumer) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockConsumerMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockConsumer)(nil).Close)) +} + +// StartConsuming mocks base method. +func (m *MockConsumer) StartConsuming(handler rabbitmq.Handler, queue string, routingKeys []string, optionFuncs ...func(*rabbitmq.ConsumeOptions)) error { + m.ctrl.T.Helper() + varargs := []interface{}{handler, queue, routingKeys} + for _, a := range optionFuncs { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "StartConsuming", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// StartConsuming indicates an expected call of StartConsuming. +func (mr *MockConsumerMockRecorder) StartConsuming(handler, queue, routingKeys interface{}, optionFuncs ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{handler, queue, routingKeys}, optionFuncs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartConsuming", reflect.TypeOf((*MockConsumer)(nil).StartConsuming), varargs...) +} diff --git a/internal/queue/mocks/listener.go b/internal/queue/mocks/listener.go new file mode 100644 index 0000000..36af49a --- /dev/null +++ b/internal/queue/mocks/listener.go @@ -0,0 +1,52 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: listener.go + +// Package mock_queue is a generated GoMock package. +package mock_queue + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + config "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/config" + queue "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/queue" + handler "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/queue/handler" +) + +// MockListener is a mock of Listener interface. +type MockListener struct { + ctrl *gomock.Controller + recorder *MockListenerMockRecorder +} + +// MockListenerMockRecorder is the mock recorder for MockListener. +type MockListenerMockRecorder struct { + mock *MockListener +} + +// NewMockListener creates a new mock instance. +func NewMockListener(ctrl *gomock.Controller) *MockListener { + mock := &MockListener{ctrl: ctrl} + mock.recorder = &MockListenerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockListener) EXPECT() *MockListenerMockRecorder { + return m.recorder +} + +// Start mocks base method. +func (m *MockListener) Start(ctx context.Context, consumer queue.Consumer, config *config.QueueConfig, handler handler.Handler) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Start", ctx, consumer, config, handler) + ret0, _ := ret[0].(error) + return ret0 +} + +// Start indicates an expected call of Start. +func (mr *MockListenerMockRecorder) Start(ctx, consumer, config, handler interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockListener)(nil).Start), ctx, consumer, config, handler) +} diff --git a/internal/repository/asdRepository.go b/internal/repository/asdRepository.go new file mode 100644 index 0000000..151dd27 --- /dev/null +++ b/internal/repository/asdRepository.go @@ -0,0 +1,119 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/apispecdoc" + + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/dto" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type AsdRepositoryImpl struct { + db *gorm.DB +} + +func (r *AsdRepositoryImpl) Save(ctx context.Context, asd *apispecdoc.ApiSpecDoc) (uint, error) { + result := r.db.WithContext(ctx).Create(&asd) + return asd.ID, result.Error +} + +func (r *AsdRepositoryImpl) Delete(ctx context.Context, asd *apispecdoc.ApiSpecDoc) error { + result := r.db.WithContext(ctx).Delete(&asd) + return result.Error +} + +func (r *AsdRepositoryImpl) Update(ctx context.Context, asd *apispecdoc.ApiSpecDoc) error { + if asd == nil { + return errors.New("asd model must not be null") + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("api_spec_doc_id = ?", asd.ID).Delete(&apispecdoc.ApiMethod{}).Error; err != nil { + return err + } + if err := tx.Where("api_spec_doc_id = ?", asd.ID).Delete(&apispecdoc.Group{}).Error; err != nil { + return err + } + return tx.Save(&asd).Error + }) +} + +func (r *AsdRepositoryImpl) FindById(ctx context.Context, id uint) (*apispecdoc.ApiSpecDoc, error) { + var specDocs []*apispecdoc.ApiSpecDoc + err := r.db.WithContext(ctx). + Where("id = ?", id). + Preload("ApiMethods.Servers"). + Preload("Groups.ApiMethods.Servers"). + Preload("ApiMethods.ExternalDoc"). + Preload("Groups.ApiMethods.ExternalDoc"). + Preload(clause.Associations).Find(&specDocs).Error + if err != nil { + return nil, err + } + switch len(specDocs) { + case 0: + return nil, nil + case 1: + return specDocs[0], nil + default: + return nil, fmt.Errorf("incorrect number of results, retrieved: %d", len(specDocs)) + } +} + +func (r *AsdRepositoryImpl) FindByHash(ctx context.Context, hash string) (*apispecdoc.ApiSpecDoc, error) { + var specDocs []*apispecdoc.ApiSpecDoc + err := r.db.WithContext(ctx).Where("md5sum = ?", hash).Find(&specDocs).Error + if err != nil { + return nil, err + } + switch len(specDocs) { + case 0: + return nil, nil + case 1: + return specDocs[0], nil + default: + return nil, fmt.Errorf("incorrect number of results, retrieved: %d", len(specDocs)) + } +} + +func (r *AsdRepositoryImpl) FindByUrl(ctx context.Context, url string) (*apispecdoc.ApiSpecDoc, error) { + var specDocs []*apispecdoc.ApiSpecDoc + err := r.db.WithContext(ctx).Where("url = ?", url).Find(&specDocs).Error + if err != nil { + return nil, err + } + switch len(specDocs) { + case 0: + return nil, nil + case 1: + return specDocs[0], nil + default: + return nil, fmt.Errorf("incorrect number of results, retrieved: %d", len(specDocs)) + } +} + +func (r *AsdRepositoryImpl) SearchShort(ctx context.Context, search string, page dto.PageRequest) (dto.Page[*apispecdoc.ApiSpecDoc], error) { + var specDocs []*apispecdoc.ApiSpecDoc + var count int64 + err := r.db.WithContext(ctx). + Where("title LIKE ?", "%"+search+"%").Or("url LIKE ?", "%"+search+"%"). + Offset(page.Page * page.PerPage).Limit(page.PerPage). + Order("id").Find(&specDocs).Error + if err != nil { + return dto.Page[*apispecdoc.ApiSpecDoc]{}, err + } + err = r.db.WithContext(ctx).Model(&apispecdoc.ApiSpecDoc{}). + Where("title LIKE ?", "%"+search+"%").Or("url LIKE ?", "%"+search+"%"). + Count(&count).Error + if err != nil { + return dto.Page[*apispecdoc.ApiSpecDoc]{}, err + } + return dto.Page[*apispecdoc.ApiSpecDoc]{Data: specDocs, Page: page.Page, PerPage: page.PerPage, Total: int(count)}, nil +} + +func NewASDRepository(db *gorm.DB) apispecdoc.AsdRepository { + return &AsdRepositoryImpl{db: db} +} diff --git a/internal/repository/asdRepository_test.go b/internal/repository/asdRepository_test.go new file mode 100644 index 0000000..8da1226 --- /dev/null +++ b/internal/repository/asdRepository_test.go @@ -0,0 +1,365 @@ +package repository + +import ( + "context" + "math" + "testing" + + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/apispecdoc" + + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/dto" + "github.com/stretchr/testify/assert" +) + +func TestSave(t *testing.T) { + servG := []*apispecdoc.Server{{URL: "test gr url", Description: "test description G"}} + apiMethG := []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test name", Servers: servG}} + groups := []*apispecdoc.Group{{Name: "test name", ApiMethods: apiMethG}} + servs := []*apispecdoc.Server{{URL: "test servG", Description: "test description 2"}} + apiMeth := []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity := apispecdoc.ApiSpecDoc{ + Title: "Trello API", + Description: "API for Trello", + Type: "1", + Md5sum: "981734bf", + Url: "test_url", + Groups: groups, + ApiMethods: apiMeth, + } + rep := AsdRepositoryImpl{db: gDb} + id, err := rep.Save(context.Background(), &entity) + assert.Equal(t, id, entity.ID) + assert.Nil(t, err) +} + +func TestSaveErrorOnMultipleApiMethodRelations(t *testing.T) { + t.Skip("Currently skipping because of bug https://github.com/go-gorm/gorm/issues/5673") + meth := &apispecdoc.ApiMethod{Path: "test/path", Name: "test name"} + apiMethodsG := []*apispecdoc.ApiMethod{meth} + apiMethods := []*apispecdoc.ApiMethod{meth} + groups := []*apispecdoc.Group{{Name: "test group", Description: "test description", ApiMethods: apiMethodsG}} + asd := apispecdoc.ApiSpecDoc{Title: "test ASD", Groups: groups, ApiMethods: apiMethods} + rep := AsdRepositoryImpl{db: gDb} + id, err := rep.Save(context.Background(), &asd) + assert.NotNil(t, err) + assert.Equal(t, id, 0) +} + +func TestDelete(t *testing.T) { + servG := []*apispecdoc.Server{{URL: "test gr url", Description: "test description G"}} + apiMethG := []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test name", Servers: servG}} + groups := []*apispecdoc.Group{{Name: "test name", ApiMethods: apiMethG}} + servs := []*apispecdoc.Server{{URL: "test servG", Description: "test description 2"}} + apiMeth := []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity := apispecdoc.ApiSpecDoc{ + Title: "Trello API", + Description: "API for Trello", + Type: "1", + Md5sum: "pook943", + Groups: groups, + Url: "www.trello.com", + ApiMethods: apiMeth, + } + rep := AsdRepositoryImpl{db: gDb} + id, err := rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + result, err := rep.FindById(context.Background(), entity.ID) + assert.Nil(t, err) + assert.NotNil(t, result) + assert.Equal(t, result.ID, entity.ID) + err = rep.Delete(context.Background(), &entity) + assert.Nil(t, err) + result, err = rep.FindById(context.Background(), entity.ID) + assert.Nil(t, err) + assert.Nil(t, result) +} + +func TestUpdate(t *testing.T) { + servG := []*apispecdoc.Server{{URL: "test gr url", Description: "test description G"}} + apiMethG := []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test name", Servers: servG}} + groups := []*apispecdoc.Group{{Name: "test name", ApiMethods: apiMethG}} + servs := []*apispecdoc.Server{{URL: "test servG", Description: "test description 2"}} + apiMeth := []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity := apispecdoc.ApiSpecDoc{ + Title: "Trello API", + Description: "API for Trello", + Type: "1", + Md5sum: "jjujwadk2", + Groups: groups, + Url: "wwww.trello.com", + ApiMethods: apiMeth, + } + rep := AsdRepositoryImpl{db: gDb} + id, err := rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + servG = []*apispecdoc.Server{{URL: "test google url", Description: "test description Google"}} + apiMethG = []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test Google", Servers: servG}} + groups = []*apispecdoc.Group{{Name: "test google", ApiMethods: apiMethG}} + servs = []*apispecdoc.Server{{URL: "test servG", Description: "test Goggle 2"}} + apiMeth = []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity = apispecdoc.ApiSpecDoc{ + Title: "Google API", + Description: "API for Google", + Type: "2", + Md5sum: "290384hrfi", + Groups: groups, + Url: "wwww.google.com", + ApiMethods: apiMeth, + } + err = rep.Update(context.Background(), &entity) + assert.Nil(t, err) + result, err := rep.FindById(context.Background(), entity.ID) + assert.Nil(t, err) + assert.NotNil(t, result) + assert.Equal(t, entity.ID, result.ID) + assert.Equal(t, entity.Title, result.Title) +} + +func TestUpdateErrorOnNil(t *testing.T) { + rep := AsdRepositoryImpl{db: gDb} + err := rep.Update(context.Background(), nil) + assert.NotNil(t, err) +} + +func TestFindById(t *testing.T) { + servG := []*apispecdoc.Server{{URL: "test gr url", Description: "test description G"}} + apiMethG := []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test name", Servers: servG}} + groups := []*apispecdoc.Group{{Name: "test name", ApiMethods: apiMethG}} + servs := []*apispecdoc.Server{{URL: "test servG", Description: "test description 2"}} + apiMeth := []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity := apispecdoc.ApiSpecDoc{ + Title: "Trello API", + Description: "API for Trello", + Type: "1", + Md5sum: "lkasjhdl343125", + Groups: groups, + Url: "wwwww.trello.com", + ApiMethods: apiMeth, + } + rep := AsdRepositoryImpl{db: gDb} + id, err := rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + servG = []*apispecdoc.Server{{URL: "test google url", Description: "test description Google"}} + apiMethG = []*apispecdoc.ApiMethod{ + { + Path: "test/path", + Name: "test Google", + Servers: servG, + ExternalDoc: &apispecdoc.ExternalDoc{URL: "some doc url"}, + }, + } + groups = []*apispecdoc.Group{{Name: "test google", ApiMethods: apiMethG}} + servs = []*apispecdoc.Server{{URL: "test servG", Description: "test Goggle 2"}} + apiMeth = []*apispecdoc.ApiMethod{ + { + Path: "test2/path", + Name: "second test method", + Servers: servs, + ExternalDoc: &apispecdoc.ExternalDoc{URL: "some doc url 2"}, + }, + } + entity = apispecdoc.ApiSpecDoc{ + Title: "Google API", + Description: "API for Google", + Type: "2", + Md5sum: "109238hrfeaslfuh", + Groups: groups, + Url: "www.google.com", + ApiMethods: apiMeth, + } + id, err = rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + result, err := rep.FindById(context.Background(), entity.ID) + assert.Nil(t, err) + assert.NotNil(t, result) + assert.Equal(t, result.ID, entity.ID) + assert.Equal(t, result.Type, entity.Type) + assert.NotNil(t, result.ApiMethods) + assert.Equal(t, len(entity.ApiMethods), len(result.ApiMethods)) + assert.Equal(t, len(entity.Groups), len(result.Groups)) + rootMethod := result.ApiMethods[0] + assert.Equal(t, 1, len(rootMethod.Servers)) + assert.NotNil(t, rootMethod.ExternalDoc) + groupEl := result.Groups[0] + groupMethod := groupEl.ApiMethods[0] + assert.Equal(t, 1, len(groupMethod.Servers)) + assert.NotNil(t, groupMethod.ExternalDoc) +} + +func TestFindByHash(t *testing.T) { + servG := []*apispecdoc.Server{{URL: "test gr url", Description: "test description G"}} + apiMethG := []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test name", Servers: servG}} + groups := []*apispecdoc.Group{{Name: "test name", ApiMethods: apiMethG}} + servs := []*apispecdoc.Server{{URL: "test servG", Description: "test description 2"}} + apiMeth := []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity := apispecdoc.ApiSpecDoc{ + Title: "Trello API", + Description: "API for Trello", + Type: "1", + Md5sum: "595f44fec1e92a71d3e9e77456ba80d1", + Groups: groups, + Url: "wwwwww.trello.com", + ApiMethods: apiMeth, + } + rep := AsdRepositoryImpl{db: gDb} + id, err := rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + result, err := rep.FindByHash(context.Background(), "595f44fec1e92a71d3e9e77456ba80d1") + assert.Nil(t, err) + assert.NotNil(t, result) + assert.Equal(t, result.Md5sum, entity.Md5sum) + assert.Equal(t, result.ID, entity.ID) +} + +func TestFindByUrl(t *testing.T) { + servG := []*apispecdoc.Server{{URL: "test gr url", Description: "test description G"}} + apiMethG := []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test name", Servers: servG}} + groups := []*apispecdoc.Group{{Name: "test name", ApiMethods: apiMethG}} + servs := []*apispecdoc.Server{{URL: "test servG", Description: "test description 2"}} + apiMeth := []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity := apispecdoc.ApiSpecDoc{ + Title: "Trello API", + Description: "API for Trello", + Type: "1", + Md5sum: "95f44fec1e92a71d3e9e77456ba80d1", + Groups: groups, + Url: "wwwwwww.trello.com", + ApiMethods: apiMeth, + } + rep := AsdRepositoryImpl{db: gDb} + id, err := rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + result, err := rep.FindByUrl(context.Background(), "wwwwwww.trello.com") + assert.Nil(t, err) + assert.NotNil(t, result) + assert.Equal(t, result.Url, entity.Url) + assert.Equal(t, result.ID, entity.ID) +} + +func TestSearchShort(t *testing.T) { + servG := []*apispecdoc.Server{{URL: "google.com", Description: "test description Google"}} + apiMethG := []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test Google", Servers: servG}} + groups := []*apispecdoc.Group{{Name: "test google", ApiMethods: apiMethG}} + servs := []*apispecdoc.Server{{URL: "test servG", Description: "test Goggle 2"}} + apiMeth := []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity := apispecdoc.ApiSpecDoc{ + Title: "Google API", + Description: "API for Google", + Type: "2", + Md5sum: "lkjafs871324r", + Groups: groups, + Url: "wwwww.google.com", + ApiMethods: apiMeth, + } + rep := AsdRepositoryImpl{db: gDb} + id, err := rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + servG = []*apispecdoc.Server{{URL: "test google url", Description: "test description Google"}} + apiMethG = []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test Google", Servers: servG}} + groups = []*apispecdoc.Group{{Name: "test google", ApiMethods: apiMethG}} + servs = []*apispecdoc.Server{{URL: "test servG", Description: "test Goggle 2"}} + apiMeth = []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity = apispecdoc.ApiSpecDoc{ + Title: "microsoft API", + Description: "API for microsoft", + Type: "2", + Md5sum: "asdf422423123jkj", + Groups: groups, + Url: "www.microsoft.com", + ApiMethods: apiMeth, + } + id, err = rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + servG = []*apispecdoc.Server{{URL: "test google url", Description: "test description Google"}} + apiMethG = []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test Google", Servers: servG}} + groups = []*apispecdoc.Group{{Name: "test google", ApiMethods: apiMethG}} + servs = []*apispecdoc.Server{{URL: "test servG", Description: "test Goggle 2"}} + apiMeth = []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity = apispecdoc.ApiSpecDoc{ + Title: "amazon API", + Description: "API for amazon", + Type: "2", + Md5sum: "asdfoqwefjipqwef00", + Groups: groups, + Url: "www.amazon.com", + ApiMethods: apiMeth, + } + id, err = rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + servG = []*apispecdoc.Server{{URL: "test google url", Description: "test description Google"}} + apiMethG = []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test Google", Servers: servG}} + groups = []*apispecdoc.Group{{Name: "test google", ApiMethods: apiMethG}} + servs = []*apispecdoc.Server{{URL: "test servG", Description: "test Goggle 2"}} + apiMeth = []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity = apispecdoc.ApiSpecDoc{ + Title: "netflix API", + Description: "API for netflix", + Type: "2", + Md5sum: "afqweqweqwe11123", + Groups: groups, + Url: "www.netflix.com", + ApiMethods: apiMeth, + } + id, err = rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + servG = []*apispecdoc.Server{{URL: "test google url", Description: "test description Google"}} + apiMethG = []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test Google", Servers: servG}} + groups = []*apispecdoc.Group{{Name: "test google", ApiMethods: apiMethG}} + servs = []*apispecdoc.Server{{URL: "test servG", Description: "test Goggle 2"}} + apiMeth = []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity = apispecdoc.ApiSpecDoc{ + Title: "apple API", + Description: "API for apple", + Type: "2", + Md5sum: "vmmvmvmvfs89304", + Groups: groups, + Url: "www.apple.com", + ApiMethods: apiMeth, + } + id, err = rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + servG = []*apispecdoc.Server{{URL: "test google url", Description: "test description Google"}} + apiMethG = []*apispecdoc.ApiMethod{{Path: "test/path", Name: "test Google", Servers: servG}} + groups = []*apispecdoc.Group{{Name: "test google", ApiMethods: apiMethG}} + servs = []*apispecdoc.Server{{URL: "test servG", Description: "test Goggle 2"}} + apiMeth = []*apispecdoc.ApiMethod{{Path: "test2/path", Name: "second test method", Servers: servs}} + entity = apispecdoc.ApiSpecDoc{ + Title: "Google 2 API", + Description: "API for Google 2", + Type: "2", + Md5sum: "bbbbb6b7bb77b", + Groups: groups, + Url: "www.Google-Google.com", + ApiMethods: apiMeth, + } + id, err = rep.Save(context.Background(), &entity) + assert.Nil(t, err) + assert.Equal(t, id, entity.ID) + number := dto.PageRequest{Page: 0, PerPage: math.MaxInt64} + result, err := rep.SearchShort(context.Background(), "Google", number) + assert.Nil(t, err) + assert.NotNil(t, result) + var resEntry *apispecdoc.ApiSpecDoc + for _, asdRec := range result.Data { + if asdRec.ID == entity.ID { + resEntry = asdRec + break + } + } + assert.NotNil(t, resEntry) + assert.Equal(t, resEntry.Title, entity.Title) + assert.Equal(t, number.Page, result.Page) + assert.Equal(t, number.PerPage, result.PerPage) + assert.GreaterOrEqual(t, result.Total, len(result.Data)) +} diff --git a/internal/repository/main_test.go b/internal/repository/main_test.go new file mode 100644 index 0000000..cf06c55 --- /dev/null +++ b/internal/repository/main_test.go @@ -0,0 +1,58 @@ +package repository + +import ( + "context" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/config" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/db" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/test/docker" + "gorm.io/gorm" + "log" + "os" + "testing" +) + +var gDb *gorm.DB + +func TestMain(m *testing.M) { + ctx := context.Background() + pgC := new(docker.PostgresContainer) + err := pgC.Start(ctx) + if err != nil { + log.Fatalf("error while starting container: %s", err) + } + gDb, err = applyMigrations() + if err != nil { + log.Fatalf("error while connecting to db: %s", err) + } + code := m.Run() + err = pgC.Stop(ctx) + if err != nil { + log.Fatalf("error while starting container: %s", err) + } + os.Exit(code) +} + +func applyMigrations() (*gorm.DB, error) { + conf, err := config.ReadConfig() + if err != nil { + return nil, err + } + gormDb, err := db.Connect(&conf.DB) + if err != nil { + return nil, err + } + sqlDb, err := gormDb.DB() + if err != nil { + return nil, err + } + fsDriver, err := iofs.New(db.FS, "migrations") + if err != nil { + return nil, err + } + err = db.Migrate(sqlDb, fsDriver, &conf.DB) + if err != nil { + return nil, err + } + return gormDb, nil +} diff --git a/internal/service/asdService.go b/internal/service/asdService.go new file mode 100644 index 0000000..2f190f7 --- /dev/null +++ b/internal/service/asdService.go @@ -0,0 +1,432 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + asdentity "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/apispecdoc" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/config" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/dto" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/logger" + "github.com/rog-golang-buddies/api_hub_common/apispecdoc" + "github.com/rog-golang-buddies/api_hub_common/apispecproto" + "time" +) + +func NewService(log logger.Logger, repo asdentity.AsdRepository, pageConf *config.PageConfig) asdentity.Service { + return &ServiceImpl{log: log, asdRepo: repo, conf: pageConf} +} + +var asdTypeMap = map[string]apispecproto.Type{ + string(apispecdoc.TypeOpenApi): apispecproto.Type_OPEN_API, +} + +var methodTypeMap = map[string]apispecproto.MethodType{ + string(apispecdoc.MethodConnect): apispecproto.MethodType_CONNECT, + string(apispecdoc.MethodGet): apispecproto.MethodType_GET, + string(apispecdoc.MethodPut): apispecproto.MethodType_PUT, + string(apispecdoc.MethodPost): apispecproto.MethodType_POST, + string(apispecdoc.MethodDelete): apispecproto.MethodType_DELETE, + string(apispecdoc.MethodOptions): apispecproto.MethodType_OPTIONS, + string(apispecdoc.MethodHead): apispecproto.MethodType_HEAD, + string(apispecdoc.MethodPatch): apispecproto.MethodType_PATCH, + string(apispecdoc.MethodTrace): apispecproto.MethodType_TRACE, +} + +var schemaTypeMap = map[apispecdoc.SchemaType]apispecproto.SchemaType{ + apispecdoc.Unknown: apispecproto.SchemaType_UNKNOWN_SCHEMA, + apispecdoc.NotDefined: apispecproto.SchemaType_NOT_DEFINED, + apispecdoc.Integer: apispecproto.SchemaType_INTEGER, + apispecdoc.Boolean: apispecproto.SchemaType_BOOLEAN, + apispecdoc.Number: apispecproto.SchemaType_NUMBER, + apispecdoc.String: apispecproto.SchemaType_STRING, + apispecdoc.Date: apispecproto.SchemaType_DATE, + apispecdoc.Array: apispecproto.SchemaType_ARRAY, + apispecdoc.Map: apispecproto.SchemaType_MAP, + apispecdoc.OneOf: apispecproto.SchemaType_ONE_OF, + apispecdoc.AnyOf: apispecproto.SchemaType_ANY_OF, + apispecdoc.AllOf: apispecproto.SchemaType_ALL_OF, + apispecdoc.Not: apispecproto.SchemaType_NOT, + apispecdoc.Object: apispecproto.SchemaType_OBJECT, +} + +var parameterTypeMap = map[apispecdoc.ParameterType]apispecproto.ParameterType{ + apispecdoc.ParameterQuery: apispecproto.ParameterType_QUERY, + apispecdoc.ParameterHeader: apispecproto.ParameterType_HEADER, + apispecdoc.ParameterPath: apispecproto.ParameterType_PATH, + apispecdoc.ParameterCookie: apispecproto.ParameterType_COOKIE, +} + +type ServiceImpl struct { + log logger.Logger + asdRepo asdentity.AsdRepository + conf *config.PageConfig +} + +func (s *ServiceImpl) Search(ctx context.Context, req *apispecproto.SearchRequest) (*apispecproto.SearchResponse, error) { + if req == nil { + s.log.Error("nil request body received") + return nil, errors.New("request body must not be nil") + } + pageReq := dto.PageRequest{} + if req.Page != nil { + pageReq.Page = int(*req.Page) - 1 + } + if pageReq.Page < 0 { + s.log.Warnf("retrieved incorrect page number: %d, must be >= 1", req.Page) + pageReq.Page = 0 + } + if req.PerPage != nil { + pageReq.PerPage = int(*req.PerPage) + } else { + pageReq.PerPage = 10 + } + if pageReq.PerPage < s.conf.MinPerPage { + s.log.Warnf("retrieved incorrect page size: %d, must be >= %d", req.PerPage, s.conf.MinPerPage) + pageReq.PerPage = s.conf.MinPerPage + } + asdPage, err := s.asdRepo.SearchShort(ctx, req.Search, pageReq) + if err != nil { + return nil, err + } + res := new(apispecproto.SearchResponse) + resDocs := make([]*apispecproto.ShortASD, 0) + for _, asd := range asdPage.Data { + resDocs = append(resDocs, &apispecproto.ShortASD{ + Id: uint32(asd.ID), + Name: asd.Title, + Description: asd.Description, + }) + } + res.ShortSpecDocs = resDocs + res.Page = &apispecproto.Page{ + Total: int32(asdPage.Total), + Current: int32(asdPage.Page) + 1, + PerPage: int32(asdPage.PerPage), + } + return res, nil +} + +func (s *ServiceImpl) Get(ctx context.Context, req *apispecproto.GetRequest) (*apispecproto.GetResponse, error) { + if req == nil { + s.log.Error("nil request body received") + return nil, errors.New("request body must not be nil") + } + apiSpecDoc, err := s.asdRepo.FindById(ctx, uint(req.Id)) + if err != nil { + s.log.Error("error while find ASD by ID: ", err) + return nil, err + } + if apiSpecDoc == nil { + s.log.Infof("API spec document not found by id %d", req.Id) + return nil, nil + } + resAsd, err := entityToFullAsd(apiSpecDoc) + if err != nil { + return nil, err + } + return &apispecproto.GetResponse{ApiSpecDoc: resAsd}, nil +} + +func (s *ServiceImpl) Save(ctx context.Context, asd *apispecdoc.ApiSpecDoc) (uint, error) { + if asd == nil { + return 0, errors.New("nil asd model received") + } + asdEntity, err := asdToEntity(asd) + if err != nil { + return 0, err + } + //Check records by md5 hash sum - if exists than all methods the same and update not required + asdByHash, err := s.asdRepo.FindByHash(ctx, asd.Md5Sum) + if err != nil { + return 0, err + } + if asdByHash != nil { + s.log.Infof("record '%s' hash '%s' no changes", asd.Title, asd.Md5Sum) + return asdByHash.ID, nil + } + //Check records by file url - if exists than need to update ASD in db (prev step didn't find matched hash - so hash changed) + asdByUrl, err := s.asdRepo.FindByUrl(ctx, asd.Url) + if err != nil { + return 0, err + } + if asdByUrl != nil { + asdByUrl.Title = asdEntity.Title + asdByUrl.Description = asdEntity.Description + asdByUrl.Type = asdEntity.Type + asdByUrl.Groups = asdEntity.Groups + asdByUrl.ApiMethods = asdEntity.ApiMethods + asdByUrl.Md5sum = asdEntity.Md5sum + asdByUrl.Url = asdEntity.Url + //clear and reattach all dependencies + err = s.asdRepo.Update(ctx, asdByUrl) + if err != nil { + return 0, err + } + s.log.Infof("record '%s' with hash '%s' updated", asd.Title, asd.Md5Sum) + return asdByUrl.ID, nil + } + s.log.Infof("create new record for '%s' hash '%s'", asd.Title, asd.Md5Sum) + return s.asdRepo.Save(ctx, asdEntity) +} + +func asdToEntity(dto *apispecdoc.ApiSpecDoc) (*asdentity.ApiSpecDoc, error) { + groups := make([]*asdentity.Group, 0, len(dto.Groups)) + for _, group := range dto.Groups { + methods, err := methodsToEntities(group.Methods) + if err != nil { + return nil, err + } + groups = append(groups, &asdentity.Group{ + Name: group.Name, + Description: group.Description, + ApiMethods: methods, + }) + } + methods, err := methodsToEntities(dto.Methods) + if err != nil { + return nil, err + } + return &asdentity.ApiSpecDoc{ + Title: dto.Title, + Description: dto.Description, + Type: string(dto.Type), + Groups: groups, + ApiMethods: methods, + Md5sum: dto.Md5Sum, + Url: dto.Url, + FetchedAt: time.Now(), + }, nil +} + +func methodToEntity(method *apispecdoc.ApiMethod) (*asdentity.ApiMethod, error) { + if method == nil { + return &asdentity.ApiMethod{}, nil + } + var params []byte + var err error + if method.Parameters != nil { + params, err = json.Marshal(method.Parameters) + if err != nil { + return nil, err + } + } + var body []byte + if method.RequestBody != nil { + body, err = json.Marshal(method.RequestBody) + if err != nil { + return nil, err + } + } + var servers []*asdentity.Server + if method.Servers != nil { + servers = make([]*asdentity.Server, 0, len(method.Servers)) + for _, server := range method.Servers { + servers = append(servers, &asdentity.Server{ + URL: server.Url, + Description: server.Description, + }) + } + } + var extDoc asdentity.ExternalDoc + if method.ExternalDoc != nil { + extDoc = asdentity.ExternalDoc{ + Description: method.ExternalDoc.Description, + URL: method.ExternalDoc.Url, + } + } + return &asdentity.ApiMethod{ + Path: method.Path, + Name: method.Name, + Description: method.Description, + Type: string(method.Type), + Parameters: string(params), + Servers: servers, + RequestBody: string(body), + ExternalDoc: &extDoc, + }, nil +} + +func methodsToEntities(methods []*apispecdoc.ApiMethod) ([]*asdentity.ApiMethod, error) { + if methods == nil { + return make([]*asdentity.ApiMethod, 0), nil + } + resMeth := make([]*asdentity.ApiMethod, 0, len(methods)) + for _, method := range methods { + methEntity, err := methodToEntity(method) + if err != nil { + return nil, err + } + resMeth = append(resMeth, methEntity) + } + return resMeth, nil +} + +func entityToFullAsd(asd *asdentity.ApiSpecDoc) (*apispecproto.FullASD, error) { + convertMethods := func(methods []*asdentity.ApiMethod) ([]*apispecproto.ApiMethod, error) { + if methods == nil { + return nil, nil + } + resMethods := make([]*apispecproto.ApiMethod, 0, len(methods)) + for _, method := range methods { + asdMethod, err := entityToFullASDMethod(method) + if err != nil { + return nil, err + } + resMethods = append(resMethods, asdMethod) + } + return resMethods, nil + } + rootMethods, err := convertMethods(asd.ApiMethods) + if err != nil { + return nil, err + } + + groups := make([]*apispecproto.Group, 0, len(asd.Groups)) + for _, group := range asd.Groups { + methods, err := convertMethods(group.ApiMethods) + if err != nil { + return nil, err + } + groups = append(groups, &apispecproto.Group{ + Name: group.Name, + Description: group.Description, + Methods: methods, + }) + } + return &apispecproto.FullASD{ + Id: uint32(asd.ID), + Title: asd.Title, + Description: asd.Description, + Url: asd.Url, + Type: apiTypeToResponse(asd.Type), + Groups: groups, + Methods: rootMethods, + }, nil +} + +func entityToFullASDMethod(method *asdentity.ApiMethod) (*apispecproto.ApiMethod, error) { + if method == nil { + return &apispecproto.ApiMethod{}, nil + } + var params []*apispecdoc.Parameter + var err error + if len(method.Parameters) != 0 { + if err = json.Unmarshal([]byte(method.Parameters), ¶ms); err != nil { + return nil, err + } + } + + var resParams []*apispecproto.Parameter + if params != nil { + resParams = make([]*apispecproto.Parameter, 0, len(params)) + for _, param := range params { + resParams = append(resParams, &apispecproto.Parameter{ + Name: param.Name, + In: paramTypeToResponse(param.In), + Description: param.Description, + Required: param.Required, + Schema: entitySchemaToResponse(param.Schema), + }) + } + } + + var body apispecdoc.RequestBody + var resBody *apispecproto.RequestBody + if len(method.RequestBody) != 0 { + if err = json.Unmarshal([]byte(method.RequestBody), &body); err != nil { + return nil, err + } + resBody = new(apispecproto.RequestBody) + resBody.Description = body.Description + resBody.Required = body.Required + if body.Content != nil { + resContent := make([]*apispecproto.RequestBody_MediaTypeObject, 0, len(body.Content)) + for _, mediaTypeObj := range body.Content { + resContent = append(resContent, &apispecproto.RequestBody_MediaTypeObject{ + MediaType: mediaTypeObj.MediaType, + Schema: entitySchemaToResponse(mediaTypeObj.Schema), + }) + } + resBody.Content = resContent + } + } + + var resServers []*apispecproto.Server + if method.Servers != nil { + resServers = make([]*apispecproto.Server, 0, len(method.Servers)) + for _, server := range method.Servers { + resServers = append(resServers, &apispecproto.Server{ + Url: server.URL, + Description: server.Description, + }) + } + } + var resExtDoc = &apispecproto.ExternalDoc{} + if method.ExternalDoc != nil { + resExtDoc.Description = method.ExternalDoc.Description + resExtDoc.Url = method.ExternalDoc.URL + } + return &apispecproto.ApiMethod{ + Path: method.Path, + Name: method.Name, + Description: method.Description, + Type: methodTypeToResponse(method.Type), + Parameters: resParams, + Servers: resServers, + RequestBody: resBody, + ExternalDoc: resExtDoc, + }, nil +} + +func entitySchemaToResponse(schema *apispecdoc.Schema) *apispecproto.Schema { + if schema == nil { + return nil + } + return &apispecproto.Schema{ + Key: schema.Key, + Type: schemaTypeToResponse(schema.Type), + Description: schema.Description, + Fields: entitySchemasToResponses(schema.Fields), + } +} + +func entitySchemasToResponses(schemas []*apispecdoc.Schema) []*apispecproto.Schema { + resSchemas := make([]*apispecproto.Schema, 0, len(schemas)) + for _, schema := range schemas { + resSchemas = append(resSchemas, entitySchemaToResponse(schema)) + } + return resSchemas +} + +func paramTypeToResponse(pt apispecdoc.ParameterType) apispecproto.ParameterType { + res, ok := parameterTypeMap[pt] + if ok { + return res + } + return apispecproto.ParameterType_UNKNOWN_PARAM +} + +func methodTypeToResponse(mt string) apispecproto.MethodType { + res, ok := methodTypeMap[mt] + if ok { + return res + } + return apispecproto.MethodType_UNKNOWN_METHOD +} + +func schemaTypeToResponse(st apispecdoc.SchemaType) apispecproto.SchemaType { + res, ok := schemaTypeMap[st] + if ok { + return res + } + return apispecproto.SchemaType_UNKNOWN_SCHEMA +} + +func apiTypeToResponse(asdT string) apispecproto.Type { + res, ok := asdTypeMap[asdT] + if ok { + return res + } + return apispecproto.Type_UNKNOWN_API +} diff --git a/internal/service/asdService_test.go b/internal/service/asdService_test.go new file mode 100644 index 0000000..ab47f14 --- /dev/null +++ b/internal/service/asdService_test.go @@ -0,0 +1,518 @@ +package service + +import ( + "context" + "errors" + "github.com/golang/mock/gomock" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/apispecdoc" + asdmock "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/apispecdoc/mock" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/config" + "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/dto" + mock_logger "github.com/rog-golang-buddies/api-hub_storage-and-update-service/internal/logger/mocks" + apispecdoc2 "github.com/rog-golang-buddies/api_hub_common/apispecdoc" + "github.com/rog-golang-buddies/api_hub_common/apispecproto" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + "testing" + "time" +) + +func TestServiceImpl_Search(t *testing.T) { + ctrl := gomock.NewController(t) + log := mock_logger.NewMockLogger(ctrl) + + repo := asdmock.NewMockAsdRepository(ctrl) + ctx := context.Background() + search := "search" + service := ServiceImpl{ + log: log, + asdRepo: repo, + conf: &config.PageConfig{MinPerPage: 2}, + } + var page, perPage int32 = 1, 10 + req := apispecproto.SearchRequest{ + Search: "search", + Page: &page, + PerPage: &perPage, + } + pageReq := dto.PageRequest{ + PerPage: int(perPage), + Page: int(page) - 1, + } + expAsd := apispecdoc.ApiSpecDoc{ + Model: gorm.Model{ + ID: 12, + }, + Title: "test title", + Description: "test description", + FetchedAt: time.Now(), + } + pageRes := dto.Page[*apispecdoc.ApiSpecDoc]{ + Data: []*apispecdoc.ApiSpecDoc{&expAsd}, + Page: pageReq.Page, + PerPage: pageReq.PerPage, + Total: 5, + } + repo.EXPECT().SearchShort(ctx, search, pageReq).Return(pageRes, nil) + result, err := service.Search(ctx, &req) + assert.Nil(t, err) + assert.NotNil(t, result) + + //Check pages + assert.Equal(t, pageRes.Page+1, int(result.Page.Current)) + assert.Equal(t, pageRes.PerPage, int(result.Page.PerPage)) + assert.Equal(t, pageRes.Total, int(result.Page.Total)) + + //Check that documents transferred + assert.Equal(t, 1, len(result.ShortSpecDocs)) + resAsd := result.ShortSpecDocs[0] + assert.Equal(t, expAsd.ID, uint(resAsd.Id)) + assert.Equal(t, expAsd.Title, resAsd.Name) + assert.Equal(t, expAsd.Description, resAsd.Description) +} + +func TestServiceImpl_SearchIncorrectPage(t *testing.T) { + ctrl := gomock.NewController(t) + log := mock_logger.NewMockLogger(ctrl) + + repo := asdmock.NewMockAsdRepository(ctrl) + ctx := context.Background() + search := "search" + minPage := 2 + service := ServiceImpl{ + log: log, + asdRepo: repo, + conf: &config.PageConfig{MinPerPage: minPage}, + } + var page, perPage int32 = 0, 0 + req := apispecproto.SearchRequest{ + Search: "search", + Page: &page, + PerPage: &perPage, + } + pageRes := dto.Page[*apispecdoc.ApiSpecDoc]{ + Data: []*apispecdoc.ApiSpecDoc{}, + Page: int(page), + PerPage: int(perPage), + Total: 5, + } + var pageReq *dto.PageRequest + repo.EXPECT().SearchShort(ctx, search, gomock.Any()). + Do(func(ctx context.Context, search string, pr dto.PageRequest) { + pageReq = &pr + }). + Return(pageRes, nil) + log.EXPECT().Warnf(gomock.Any(), gomock.Any()).Times(2) + result, err := service.Search(ctx, &req) + assert.Nil(t, err) + assert.NotNil(t, result) + assert.NotNil(t, pageReq) + assert.Equal(t, 0, pageReq.Page) + assert.Equal(t, minPage, pageReq.PerPage) +} + +func TestServiceImpl_Get(t *testing.T) { + ctrl := gomock.NewController(t) + log := mock_logger.NewMockLogger(ctrl) + + repo := asdmock.NewMockAsdRepository(ctrl) + ctx := context.Background() + + service := ServiceImpl{ + log: log, + asdRepo: repo, + } + + req := apispecproto.GetRequest{Id: 5} + var docId uint = 54 + var methodId uint = 5 + var groupId uint = 4 + expExtDoc := apispecdoc.ExternalDoc{ + ID: 2, + Description: "test description 1", + URL: "test url 1", + ApiMethodID: &methodId, + } + expMethod := apispecdoc.ApiMethod{ + ID: methodId, + Path: "test path", + Name: "meth name", + Description: "method description", + Type: "OPEN_API", + Parameters: `[{"Name":"par1","In":"HEADER","Description":"par1 d", "Required":false}]`, + RequestBody: `{"Description":"description","Required":false,"Content":[{"MediaType":"application/json","Schema":{"Key":"","Type":"INTEGER","Description":"body description"}}]}`, + Servers: []*apispecdoc.Server{ + { + ID: 2, + URL: "url ", + Description: "description", + ApiMethodID: &methodId, + }, + }, + ExternalDoc: &expExtDoc, + ApiSpecDocID: &docId, + } + expGrExtDoc := apispecdoc.ExternalDoc{ + ID: 3, + Description: "test description 2", + URL: "test url 3", + ApiMethodID: &methodId, + } + expGrMethod := apispecdoc.ApiMethod{ + ID: methodId, + Path: "test path", + Name: "meth name", + Description: "method description", + Type: "test type", + Servers: []*apispecdoc.Server{ + { + ID: 2, + URL: "url", + Description: "description", + ApiMethodID: &methodId, + }, + }, + ExternalDoc: &expGrExtDoc, + GroupID: &groupId, + } + expGroup := apispecdoc.Group{ + ID: 4, + Name: "test name", + Description: "test description", + ApiSpecDocID: &docId, + ApiMethods: []*apispecdoc.ApiMethod{&expGrMethod}, + } + expAsd := apispecdoc.ApiSpecDoc{ + Model: gorm.Model{ + ID: docId, + }, + Title: "test title", + Description: "test description", + Type: "OPEN_API", + Groups: []*apispecdoc.Group{&expGroup}, + ApiMethods: []*apispecdoc.ApiMethod{&expMethod}, + Md5sum: "test sum", + Url: "url", + FetchedAt: time.Now(), + } + + repo.EXPECT().FindById(ctx, uint(req.Id)).Return(&expAsd, nil) + resAsd, err := service.Get(ctx, &req) + assert.Nil(t, err) + assert.NotNil(t, resAsd) + + //Check result root + fullAsd := resAsd.GetApiSpecDoc() + assert.Equal(t, expAsd.ID, uint(fullAsd.Id)) + assert.Equal(t, apispecproto.Type_OPEN_API, fullAsd.Type) + assert.Equal(t, expAsd.Title, fullAsd.Title) + assert.Equal(t, expAsd.Description, fullAsd.Description) + assert.Equal(t, expAsd.Url, fullAsd.Url) + assert.Equal(t, 1, len(fullAsd.Methods)) + assert.Equal(t, 1, len(fullAsd.Groups)) + //Check root method + resRootMeth := fullAsd.Methods[0] + assert.Equal(t, expMethod.Path, resRootMeth.Path) + assert.Equal(t, expMethod.Name, resRootMeth.Name) + assert.Equal(t, expMethod.Description, resRootMeth.Description) + assert.Equal(t, 1, len(resRootMeth.Parameters)) + assert.NotNil(t, resRootMeth.RequestBody) + assert.NotNil(t, resRootMeth.RequestBody.Content) + assert.Equal(t, 1, len(resRootMeth.RequestBody.Content)) + assert.NotNil(t, resRootMeth.ExternalDoc) + resExtDoc := resRootMeth.ExternalDoc + assert.Equal(t, expExtDoc.URL, resExtDoc.Url) + assert.Equal(t, expExtDoc.Description, resExtDoc.Description) + + resGroup := fullAsd.Groups[0] + assert.Equal(t, expGroup.Name, resGroup.Name) + + //Check group method + resGrMeth := resGroup.Methods[0] + assert.Equal(t, expGrMethod.Name, resGrMeth.Name) + assert.Equal(t, expGrMethod.Description, resGrMeth.Description) + assert.Nil(t, resGrMeth.Parameters) + assert.Nil(t, resGrMeth.RequestBody) + assert.NotNil(t, resGrMeth.ExternalDoc) + resGrExtDoc := resGrMeth.ExternalDoc + assert.Equal(t, expGrExtDoc.URL, resGrExtDoc.Url) + assert.Equal(t, expGrExtDoc.Description, resGrExtDoc.Description) +} + +func TestServiceImpl_GetNilArgError(t *testing.T) { + ctrl := gomock.NewController(t) + log := mock_logger.NewMockLogger(ctrl) + + repo := asdmock.NewMockAsdRepository(ctrl) + ctx := context.Background() + + service := ServiceImpl{ + log: log, + asdRepo: repo, + conf: &config.PageConfig{MinPerPage: 2}, + } + log.EXPECT().Error(gomock.Any()) + res, err := service.Get(ctx, nil) + assert.Nil(t, res) + assert.NotNil(t, err) +} + +func TestServiceImpl_GetFindByIdError(t *testing.T) { + ctrl := gomock.NewController(t) + log := mock_logger.NewMockLogger(ctrl) + + repo := asdmock.NewMockAsdRepository(ctrl) + ctx := context.Background() + + service := ServiceImpl{ + log: log, + asdRepo: repo, + } + log.EXPECT().Error(gomock.Any()) + var reqId uint = 54 + expErr := errors.New("some error on find query") + repo.EXPECT().FindById(ctx, reqId).Return(nil, expErr) + res, err := service.Get(ctx, &apispecproto.GetRequest{Id: uint32(reqId)}) + assert.Equal(t, expErr, err) + assert.Nil(t, res) +} + +func TestServiceImpl_GetNotFoundNil(t *testing.T) { + ctrl := gomock.NewController(t) + log := mock_logger.NewMockLogger(ctrl) + + repo := asdmock.NewMockAsdRepository(ctrl) + ctx := context.Background() + + service := ServiceImpl{ + log: log, + asdRepo: repo, + } + log.EXPECT().Infof(gomock.Any(), gomock.Any()) + var reqId uint = 54 + repo.EXPECT().FindById(ctx, reqId).Return(nil, nil) + res, err := service.Get(ctx, &apispecproto.GetRequest{Id: uint32(reqId)}) + assert.Nil(t, err) + assert.Nil(t, res) +} + +func TestServiceImpl_SaveDuplicateByHashReturnExistingId(t *testing.T) { + ctrl := gomock.NewController(t) + log := mock_logger.NewMockLogger(ctrl) + + repo := asdmock.NewMockAsdRepository(ctrl) + ctx := context.Background() + + service := ServiceImpl{ + log: log, + asdRepo: repo, + } + hash := "duplicate hash" + asdReq := apispecdoc2.ApiSpecDoc{ + Title: "test title", + Description: "test description", + Type: "OPEN_API", + Groups: nil, + Methods: nil, + Md5Sum: hash, + Url: "test url", + } + var expId uint = 54 + asdRes := apispecdoc.ApiSpecDoc{ + Model: gorm.Model{ID: expId}, + Md5sum: hash, + } + repo.EXPECT().FindByHash(ctx, hash).Return(&asdRes, nil) + log.EXPECT().Infof(gomock.Any(), gomock.Any()).Times(1) + id, err := service.Save(ctx, &asdReq) + assert.Equal(t, expId, id) + assert.Nil(t, err) +} + +func TestServiceImpl_SaveRecordHashChangedCallUpdate(t *testing.T) { + ctrl := gomock.NewController(t) + log := mock_logger.NewMockLogger(ctrl) + + repo := asdmock.NewMockAsdRepository(ctrl) + ctx := context.Background() + + service := ServiceImpl{ + log: log, + asdRepo: repo, + } + group1 := apispecdoc2.Group{ + Name: "Group 1", + Description: "Group 1 d", + } + group2 := apispecdoc2.Group{ + Name: "Group 2", + Description: "Group 2 d", + } + groups := []*apispecdoc2.Group{&group1, &group2} + method1 := apispecdoc2.ApiMethod{Name: "method 1", Description: "Description 1"} + method2 := apispecdoc2.ApiMethod{Name: "method 2", Description: "Description 2"} + methods := []*apispecdoc2.ApiMethod{&method1, &method2} + newHash := "new hash" + asdReq := apispecdoc2.ApiSpecDoc{ + Title: "test title", + Description: "test description", + Type: "OPEN_API", + Groups: groups, + Methods: methods, + Md5Sum: newHash, + Url: "test url", + } + var expId uint = 54 + oldHash := "old hash" + expAsdRes := apispecdoc.ApiSpecDoc{ + Model: gorm.Model{ID: expId}, + Title: "some title", + Description: "some description", + Type: "UNKNOWN", + Md5sum: oldHash, + } + repo.EXPECT().FindByHash(ctx, newHash).Return(nil, nil) + repo.EXPECT().FindByUrl(ctx, asdReq.Url).Return(&expAsdRes, nil) + repo.EXPECT().Update(ctx, &expAsdRes).Return(nil) + log.EXPECT().Infof(gomock.Any(), gomock.Any()).Times(1) + resId, err := service.Save(ctx, &asdReq) + assert.Nil(t, err) + assert.Equal(t, expId, resId) + assert.Equal(t, expAsdRes.Title, asdReq.Title) + assert.Equal(t, expAsdRes.Url, asdReq.Url) + assert.Equal(t, newHash, expAsdRes.Md5sum) + assert.Equal(t, expAsdRes.Description, asdReq.Description) + assert.Equal(t, 2, len(expAsdRes.Groups)) + assert.Equal(t, 2, len(expAsdRes.ApiMethods)) +} + +func TestServiceImpl_Save(t *testing.T) { + ctrl := gomock.NewController(t) + log := mock_logger.NewMockLogger(ctrl) + + repo := asdmock.NewMockAsdRepository(ctrl) + ctx := context.Background() + + service := ServiceImpl{ + log: log, + asdRepo: repo, + } + asdReq := apispecdoc2.ApiSpecDoc{ + Title: "test title", + Description: "test description", + Type: "OPEN_API", + Md5Sum: "some request hash", + Url: "test url", + Methods: []*apispecdoc2.ApiMethod{ + { + Path: "test path", + Name: "meth name", + Description: "method description", + Type: "test type", + Servers: []*apispecdoc2.Server{ + { + Url: "url", + Description: "description", + }, + }, + }, + }, + Groups: []*apispecdoc2.Group{ + { + Name: "group", + Description: "gr description", + Methods: []*apispecdoc2.ApiMethod{ + { + Path: "test path", + Name: "meth name", + Description: "method description", + RequestBody: &apispecdoc2.RequestBody{ + Description: "Body description", + Content: []*apispecdoc2.MediaTypeObject{ + { + MediaType: "application/json", + Schema: &apispecdoc2.Schema{ + Key: "key", + Type: "INTEGER", + Description: "Field description", + }, + }, + }, + Required: false, + }, + Type: "test type", + Servers: []*apispecdoc2.Server{ + { + Url: "url", + Description: "description", + }, + }, + ExternalDoc: &apispecdoc2.ExternalDoc{ + Description: "Ext doc description", + Url: "ext doc url", + }, + }, + }, + }, + }, + } + var expId uint = 78 + var saveArg *apispecdoc.ApiSpecDoc + repo.EXPECT().FindByHash(ctx, asdReq.Md5Sum).Return(nil, nil) + repo.EXPECT().FindByUrl(ctx, asdReq.Url).Return(nil, nil) + repo.EXPECT().Save(ctx, gomock.Any()).Do(func(ctx context.Context, arg *apispecdoc.ApiSpecDoc) { + saveArg = arg + }).Return(expId, nil) + log.EXPECT().Infof(gomock.Any(), gomock.Any()).Times(1) + + asdRes, err := service.Save(ctx, &asdReq) + assert.Nil(t, err) + assert.NotNil(t, asdRes) + assert.NotNil(t, saveArg) + assert.Equal(t, asdReq.Title, saveArg.Title) + assert.Equal(t, asdReq.Description, saveArg.Description) + assert.Equal(t, asdReq.Url, saveArg.Url) + assert.Equal(t, asdReq.Md5Sum, saveArg.Md5sum) +} + +func TestServiceImpl_SaveFindByHashError(t *testing.T) { + ctrl := gomock.NewController(t) + log := mock_logger.NewMockLogger(ctrl) + + repo := asdmock.NewMockAsdRepository(ctrl) + ctx := context.Background() + + service := ServiceImpl{ + log: log, + asdRepo: repo, + } + asdReq := apispecdoc2.ApiSpecDoc{ + Title: "test title", + Description: "test description", + Type: "OPEN_API", + Md5Sum: "some request hash", + Url: "test url", + } + expErr := errors.New("test error") + repo.EXPECT().FindByHash(ctx, asdReq.Md5Sum).Return(nil, expErr) + + asdRes, err := service.Save(ctx, &asdReq) + assert.NotNil(t, asdRes) + assert.Equal(t, expErr, err) +} + +func TestServiceImpl_SaveErrorOnNil(t *testing.T) { + ctrl := gomock.NewController(t) + log := mock_logger.NewMockLogger(ctrl) + + repo := asdmock.NewMockAsdRepository(ctrl) + ctx := context.Background() + + service := ServiceImpl{ + log: log, + asdRepo: repo, + } + id, err := service.Save(ctx, nil) + assert.NotNil(t, err) + assert.Equal(t, uint(0), id) +}