diff --git a/api/handler/v1/org.go b/api/handler/v1/org.go index 2e60c8e77..418f1ac95 100644 --- a/api/handler/v1/org.go +++ b/api/handler/v1/org.go @@ -8,6 +8,7 @@ import ( grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "github.com/odpf/shield/internal/org" + "github.com/odpf/shield/model" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -18,25 +19,17 @@ import ( ) type OrganizationService interface { - GetOrganization(ctx context.Context, id string) (org.Organization, error) - CreateOrganization(ctx context.Context, org org.Organization) (org.Organization, error) - ListOrganizations(ctx context.Context) ([]org.Organization, error) - UpdateOrganization(ctx context.Context, toUpdate org.Organization) (org.Organization, error) + Get(ctx context.Context, id string) (model.Organization, error) + Create(ctx context.Context, org model.Organization) (model.Organization, error) + List(ctx context.Context) ([]model.Organization, error) + Update(ctx context.Context, toUpdate model.Organization) (model.Organization, error) } -var ( - grpcInternalServerError = status.Errorf(codes.Internal, internalServerError.Error()) - grpcBadBodyError = status.Error(codes.InvalidArgument, badRequestError.Error()) -) - -// HTTP Codes defined here: -// https://github.com/grpc-ecosystem/grpc-gateway/blob/master/runtime/errors.go#L36 - func (v Dep) ListOrganizations(ctx context.Context, request *shieldv1.ListOrganizationsRequest) (*shieldv1.ListOrganizationsResponse, error) { logger := grpczap.Extract(ctx) var orgs []*shieldv1.Organization - orgList, err := v.OrgService.ListOrganizations(ctx) + orgList, err := v.OrgService.List(ctx) if err != nil { logger.Error(err.Error()) return nil, grpcInternalServerError @@ -76,7 +69,7 @@ func (v Dep) CreateOrganization(ctx context.Context, request *shieldv1.CreateOrg slug = generateSlug(request.GetBody().Name) } - newOrg, err := v.OrgService.CreateOrganization(ctx, org.Organization{ + newOrg, err := v.OrgService.Create(ctx, model.Organization{ Name: request.GetBody().Name, Slug: slug, Metadata: metaDataMap, @@ -106,7 +99,7 @@ func (v Dep) CreateOrganization(ctx context.Context, request *shieldv1.CreateOrg func (v Dep) GetOrganization(ctx context.Context, request *shieldv1.GetOrganizationRequest) (*shieldv1.GetOrganizationResponse, error) { logger := grpczap.Extract(ctx) - fetchedOrg, err := v.OrgService.GetOrganization(ctx, request.GetId()) + fetchedOrg, err := v.OrgService.Get(ctx, request.GetId()) if err != nil { logger.Error(err.Error()) switch { @@ -142,7 +135,7 @@ func (v Dep) UpdateOrganization(ctx context.Context, request *shieldv1.UpdateOrg return nil, grpcBadBodyError } - updatedOrg, err := v.OrgService.UpdateOrganization(ctx, org.Organization{ + updatedOrg, err := v.OrgService.Update(ctx, model.Organization{ Id: request.GetId(), Name: request.GetBody().Name, Slug: request.GetBody().Slug, @@ -163,7 +156,7 @@ func (v Dep) UpdateOrganization(ctx context.Context, request *shieldv1.UpdateOrg return &shieldv1.UpdateOrganizationResponse{Organization: &orgPB}, nil } -func transformOrgToPB(org org.Organization) (shieldv1.Organization, error) { +func transformOrgToPB(org model.Organization) (shieldv1.Organization, error) { metaData, err := structpb.NewStruct(mapOfInterfaceValues(org.Metadata)) if err != nil { return shieldv1.Organization{}, err diff --git a/api/handler/v1/org_test.go b/api/handler/v1/org_test.go index cf61dbca0..e778063c5 100644 --- a/api/handler/v1/org_test.go +++ b/api/handler/v1/org_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "github.com/odpf/shield/internal/org" - "github.com/stretchr/testify/assert" + "github.com/odpf/shield/model" + "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/structpb" @@ -18,7 +18,7 @@ import ( shieldv1 "go.buf.build/odpf/gw/odpf/proton/odpf/shield/v1" ) -var testOrgMap = map[string]org.Organization{ +var testOrgMap = map[string]model.Organization{ "9f256f86-31a3-11ec-8d3d-0242ac130003": { Id: "9f256f86-31a3-11ec-8d3d-0242ac130003", Name: "Org 1", @@ -42,15 +42,15 @@ func TestListOrganizations(t *testing.T) { }{ { title: "error in Org Service", - mockOrgSrv: mockOrgSrv{ListOrganizationsFunc: func(ctx context.Context) (organizations []org.Organization, err error) { - return []org.Organization{}, errors.New("some error") + mockOrgSrv: mockOrgSrv{ListOrganizationsFunc: func(ctx context.Context) (organizations []model.Organization, err error) { + return []model.Organization{}, errors.New("some error") }}, want: nil, err: status.Errorf(codes.Internal, internalServerError.Error()), }, { title: "success", - mockOrgSrv: mockOrgSrv{ListOrganizationsFunc: func(ctx context.Context) (organizations []org.Organization, err error) { - var testOrgList []org.Organization + mockOrgSrv: mockOrgSrv{ListOrganizationsFunc: func(ctx context.Context) (organizations []model.Organization, err error) { + var testOrgList []model.Organization for _, o := range testOrgMap { testOrgList = append(testOrgList, o) } @@ -98,8 +98,8 @@ func TestCreateOrganization(t *testing.T) { }{ { title: "error in fetching org list", - mockOrgSrv: mockOrgSrv{CreateOrganizationFunc: func(ctx context.Context, o org.Organization) (org.Organization, error) { - return org.Organization{}, errors.New("some error") + mockOrgSrv: mockOrgSrv{CreateOrganizationFunc: func(ctx context.Context, o model.Organization) (model.Organization, error) { + return model.Organization{}, errors.New("some error") }}, req: &shieldv1.CreateOrganizationRequest{Body: &shieldv1.OrganizationRequestBody{ Name: "some org", @@ -125,8 +125,8 @@ func TestCreateOrganization(t *testing.T) { }, { title: "success", - mockOrgSrv: mockOrgSrv{CreateOrganizationFunc: func(ctx context.Context, o org.Organization) (org.Organization, error) { - return org.Organization{ + mockOrgSrv: mockOrgSrv{CreateOrganizationFunc: func(ctx context.Context, o model.Organization) (model.Organization, error) { + return model.Organization{ Id: "new-abc", Name: "some org", Slug: "abc", @@ -167,24 +167,24 @@ func TestCreateOrganization(t *testing.T) { } type mockOrgSrv struct { - GetOrganizationFunc func(ctx context.Context, id string) (org.Organization, error) - CreateOrganizationFunc func(ctx context.Context, org org.Organization) (org.Organization, error) - ListOrganizationsFunc func(ctx context.Context) ([]org.Organization, error) - UpdateOrganizationFunc func(ctx context.Context, toUpdate org.Organization) (org.Organization, error) + GetOrganizationFunc func(ctx context.Context, id string) (model.Organization, error) + CreateOrganizationFunc func(ctx context.Context, org model.Organization) (model.Organization, error) + ListOrganizationsFunc func(ctx context.Context) ([]model.Organization, error) + UpdateOrganizationFunc func(ctx context.Context, toUpdate model.Organization) (model.Organization, error) } -func (m mockOrgSrv) GetOrganization(ctx context.Context, id string) (org.Organization, error) { +func (m mockOrgSrv) Get(ctx context.Context, id string) (model.Organization, error) { return m.GetOrganizationFunc(ctx, id) } -func (m mockOrgSrv) CreateOrganization(ctx context.Context, org org.Organization) (org.Organization, error) { +func (m mockOrgSrv) Create(ctx context.Context, org model.Organization) (model.Organization, error) { return m.CreateOrganizationFunc(ctx, org) } -func (m mockOrgSrv) ListOrganizations(ctx context.Context) ([]org.Organization, error) { +func (m mockOrgSrv) List(ctx context.Context) ([]model.Organization, error) { return m.ListOrganizationsFunc(ctx) } -func (m mockOrgSrv) UpdateOrganization(ctx context.Context, toUpdate org.Organization) (org.Organization, error) { +func (m mockOrgSrv) Update(ctx context.Context, toUpdate model.Organization) (model.Organization, error) { return m.UpdateOrganizationFunc(ctx, toUpdate) } diff --git a/api/handler/v1/project.go b/api/handler/v1/project.go index e21d9450b..19c20e446 100644 --- a/api/handler/v1/project.go +++ b/api/handler/v1/project.go @@ -2,25 +2,160 @@ package v1 import ( "context" + "errors" + "strings" + + grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + + "github.com/odpf/shield/internal/project" + "github.com/odpf/shield/model" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" shieldv1 "go.buf.build/odpf/gw/odpf/proton/odpf/shield/v1" ) +var grpcProjectNotFoundErr = status.Errorf(codes.NotFound, "project doesn't exist") + +type ProjectService interface { + Get(ctx context.Context, id string) (model.Project, error) + Create(ctx context.Context, project model.Project) (model.Project, error) + List(ctx context.Context) ([]model.Project, error) + Update(ctx context.Context, toUpdate model.Project) (model.Project, error) +} + func (v Dep) ListProjects(ctx context.Context, request *shieldv1.ListProjectsRequest) (*shieldv1.ListProjectsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method not implemented") + logger := grpczap.Extract(ctx) + var projects []*shieldv1.Project + + projectList, err := v.ProjectService.List(ctx) + if err != nil { + logger.Error(err.Error()) + return nil, grpcInternalServerError + } + + for _, v := range projectList { + projectPB, err := transformProjectToPB(v) + if err != nil { + logger.Error(err.Error()) + return nil, grpcInternalServerError + } + + projects = append(projects, &projectPB) + } + + return &shieldv1.ListProjectsResponse{Projects: projects}, nil } func (v Dep) CreateProject(ctx context.Context, request *shieldv1.CreateProjectRequest) (*shieldv1.CreateProjectResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method not implemented") + logger := grpczap.Extract(ctx) + metaDataMap, err := mapOfStringValues(request.GetBody().Metadata.AsMap()) + if err != nil { + logger.Error(err.Error()) + return nil, grpcBadBodyError + } + + slug := request.GetBody().Slug + if strings.TrimSpace(slug) == "" { + slug = generateSlug(request.GetBody().Name) + } + + newProject, err := v.ProjectService.Create(ctx, model.Project{ + Name: request.GetBody().Name, + Slug: slug, + Metadata: metaDataMap, + //Organization: org.Organization{Id: "ACCEPT"}, + }) + + if err != nil { + logger.Error(err.Error()) + return nil, grpcInternalServerError + } + + metaData, err := structpb.NewStruct(mapOfInterfaceValues(newProject.Metadata)) + if err != nil { + logger.Error(err.Error()) + return nil, grpcInternalServerError + } + + return &shieldv1.CreateProjectResponse{Project: &shieldv1.Project{ + Id: newProject.Id, + Name: newProject.Name, + Slug: newProject.Slug, + Metadata: metaData, + CreatedAt: timestamppb.New(newProject.CreatedAt), + UpdatedAt: timestamppb.New(newProject.UpdatedAt), + }}, nil } func (v Dep) GetProject(ctx context.Context, request *shieldv1.GetProjectRequest) (*shieldv1.GetProjectResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method not implemented") + logger := grpczap.Extract(ctx) + + fetchedProject, err := v.ProjectService.Get(ctx, request.GetId()) + if err != nil { + logger.Error(err.Error()) + switch { + case errors.Is(err, project.ProjectDoesntExist): + return nil, grpcProjectNotFoundErr + case errors.Is(err, project.InvalidUUID): + return nil, grpcBadBodyError + default: + return nil, grpcInternalServerError + } + } + + projectPB, err := transformProjectToPB(fetchedProject) + if err != nil { + logger.Error(err.Error()) + return nil, grpcInternalServerError + } + + return &shieldv1.GetProjectResponse{Project: &projectPB}, nil } func (v Dep) UpdateProject(ctx context.Context, request *shieldv1.UpdateProjectRequest) (*shieldv1.UpdateProjectResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method not implemented") + logger := grpczap.Extract(ctx) + + metaDataMap, err := mapOfStringValues(request.GetBody().Metadata.AsMap()) + if err != nil { + return nil, grpcBadBodyError + } + + updatedProject, err := v.ProjectService.Update(ctx, model.Project{ + Id: request.GetId(), + Name: request.GetBody().Name, + Slug: request.GetBody().Slug, + Metadata: metaDataMap, + }) + if err != nil { + logger.Error(err.Error()) + return nil, grpcInternalServerError + } + + projectPB, err := transformProjectToPB(updatedProject) + if err != nil { + logger.Error(err.Error()) + return nil, grpcInternalServerError + } + + return &shieldv1.UpdateProjectResponse{Project: &projectPB}, nil +} + +func transformProjectToPB(prj model.Project) (shieldv1.Project, error) { + metaData, err := structpb.NewStruct(mapOfInterfaceValues(prj.Metadata)) + if err != nil { + return shieldv1.Project{}, err + } + + return shieldv1.Project{ + Id: prj.Id, + Name: prj.Name, + Slug: prj.Slug, + Metadata: metaData, + CreatedAt: timestamppb.New(prj.CreatedAt), + UpdatedAt: timestamppb.New(prj.UpdatedAt), + }, nil } diff --git a/api/handler/v1/project_test.go b/api/handler/v1/project_test.go new file mode 100644 index 000000000..5a181d7c7 --- /dev/null +++ b/api/handler/v1/project_test.go @@ -0,0 +1,287 @@ +package v1 + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/odpf/shield/internal/project" + "github.com/odpf/shield/model" + + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + shieldv1 "go.buf.build/odpf/gw/odpf/proton/odpf/shield/v1" +) + +var testProjectID = "ab657ae7-8c9e-45eb-9862-dd9ceb6d5c71" + +var testProjectMap = map[string]model.Project{ + "ab657ae7-8c9e-45eb-9862-dd9ceb6d5c71": { + Id: "ab657ae7-8c9e-45eb-9862-dd9ceb6d5c71", + Name: "Prj 1", + Slug: "prj-1", + Metadata: map[string]string{ + "email": "org1@org1.com", + }, + CreatedAt: time.Time{}, + UpdatedAt: time.Time{}, + }, + "c7772c63-fca4-4c7c-bf93-c8f85115de4b": { + Id: "c7772c63-fca4-4c7c-bf93-c8f85115de4b", + Name: "Prj 2", + Slug: "prj-2", + Metadata: map[string]string{ + "email": "org1@org2.com", + }, + CreatedAt: time.Time{}, + UpdatedAt: time.Time{}, + }, +} + +func TestCreateProject(t *testing.T) { + t.Parallel() + + table := []struct { + title string + mockProjectSrv mockProject + req *shieldv1.CreateProjectRequest + want *shieldv1.CreateProjectResponse + err error + }{ + { + title: "error in metadata parsing", + req: &shieldv1.CreateProjectRequest{Body: &shieldv1.ProjectRequestBody{ + Name: "odpf 1", + Slug: "odpf-1", + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "count": structpb.NewNumberValue(10), + }, + }, + }}, + err: grpcBadBodyError, + }, + { + title: "error in service", + req: &shieldv1.CreateProjectRequest{Body: &shieldv1.ProjectRequestBody{ + Name: "odpf 1", + Slug: "odpf-1", + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "team": structpb.NewStringValue("Platforms"), + }, + }, + }}, + mockProjectSrv: mockProject{CreateProjectFunc: func(ctx context.Context, prj model.Project) (model.Project, error) { + return model.Project{}, errors.New("some service error") + }}, + err: grpcInternalServerError, + }, + { + title: "success", + req: &shieldv1.CreateProjectRequest{Body: &shieldv1.ProjectRequestBody{ + Name: "odpf 1", + Slug: "odpf-1", + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "team": structpb.NewStringValue("Platforms"), + }, + }, + }}, + mockProjectSrv: mockProject{CreateProjectFunc: func(ctx context.Context, prj model.Project) (model.Project, error) { + return testProjectMap[testProjectID], nil + }}, + want: &shieldv1.CreateProjectResponse{Project: &shieldv1.Project{ + Id: testProjectMap[testProjectID].Id, + Name: testProjectMap[testProjectID].Name, + Slug: testProjectMap[testProjectID].Slug, + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "email": structpb.NewStringValue("org1@org1.com"), + }, + }, + CreatedAt: timestamppb.New(time.Time{}), + UpdatedAt: timestamppb.New(time.Time{}), + }}, + err: nil, + }, + } + + for _, tt := range table { + t.Run(tt.title, func(t *testing.T) { + t.Parallel() + + mockDep := Dep{ProjectService: tt.mockProjectSrv} + resp, err := mockDep.CreateProject(context.Background(), tt.req) + assert.EqualValues(t, tt.want, resp) + assert.EqualValues(t, tt.err, err) + }) + } +} + +func TestListProjects(t *testing.T) { + t.Parallel() + + table := []struct { + title string + mockProjectSrv mockProject + req *shieldv1.ListProjectsRequest + want *shieldv1.ListProjectsResponse + err error + }{ + { + title: "error in service", + req: &shieldv1.ListProjectsRequest{}, + mockProjectSrv: mockProject{ListProjectFunc: func(ctx context.Context) ([]model.Project, error) { + return []model.Project{}, errors.New("some store error") + }}, + err: grpcInternalServerError, + }, + { + title: "success", + req: &shieldv1.ListProjectsRequest{}, + mockProjectSrv: mockProject{ListProjectFunc: func(ctx context.Context) ([]model.Project, error) { + var prjs []model.Project + + for _, v := range testProjectMap { + prjs = append(prjs, v) + } + + return prjs, nil + }}, + want: &shieldv1.ListProjectsResponse{Projects: []*shieldv1.Project{ + { + Id: "ab657ae7-8c9e-45eb-9862-dd9ceb6d5c71", + Name: "Prj 1", + Slug: "prj-1", + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "email": structpb.NewStringValue("org1@org1.com"), + }, + }, + CreatedAt: timestamppb.New(time.Time{}), + UpdatedAt: timestamppb.New(time.Time{}), + }, + { + Id: "c7772c63-fca4-4c7c-bf93-c8f85115de4b", + Name: "Prj 2", + Slug: "prj-2", + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "email": structpb.NewStringValue("org1@org2.com"), + }, + }, + CreatedAt: timestamppb.New(time.Time{}), + UpdatedAt: timestamppb.New(time.Time{}), + }, + }}, + err: nil, + }, + } + + for _, tt := range table { + t.Run(tt.title, func(t *testing.T) { + t.Parallel() + + mockDep := Dep{ProjectService: tt.mockProjectSrv} + resp, err := mockDep.ListProjects(context.Background(), tt.req) + assert.EqualValues(t, tt.want, resp) + assert.EqualValues(t, tt.err, err) + }) + } +} + +func TestGetProject(t *testing.T) { + t.Parallel() + + table := []struct { + title string + mockProjectSrv mockProject + req *shieldv1.GetProjectRequest + want *shieldv1.GetProjectResponse + err error + }{ + { + title: "project doesnt exist", + req: &shieldv1.GetProjectRequest{}, + mockProjectSrv: mockProject{GetProjectFunc: func(ctx context.Context, id string) (model.Project, error) { + return model.Project{}, project.ProjectDoesntExist + }}, + err: grpcProjectNotFoundErr, + }, + { + title: "uuid syntax error", + req: &shieldv1.GetProjectRequest{}, + mockProjectSrv: mockProject{GetProjectFunc: func(ctx context.Context, id string) (model.Project, error) { + return model.Project{}, project.InvalidUUID + }}, + err: grpcBadBodyError, + }, + { + title: "service error", + req: &shieldv1.GetProjectRequest{}, + mockProjectSrv: mockProject{GetProjectFunc: func(ctx context.Context, id string) (model.Project, error) { + return model.Project{}, errors.New("some error") + }}, + err: grpcInternalServerError, + }, + { + title: "success", + req: &shieldv1.GetProjectRequest{}, + mockProjectSrv: mockProject{GetProjectFunc: func(ctx context.Context, id string) (model.Project, error) { + return testProjectMap[testProjectID], nil + }}, + want: &shieldv1.GetProjectResponse{Project: &shieldv1.Project{ + Id: testProjectMap[testProjectID].Id, + Name: testProjectMap[testProjectID].Name, + Slug: testProjectMap[testProjectID].Slug, + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "email": structpb.NewStringValue("org1@org1.com"), + }, + }, + CreatedAt: timestamppb.New(time.Time{}), + UpdatedAt: timestamppb.New(time.Time{}), + }}, + err: nil, + }, + } + + for _, tt := range table { + t.Run(tt.title, func(t *testing.T) { + t.Parallel() + + mockDep := Dep{ProjectService: tt.mockProjectSrv} + resp, err := mockDep.GetProject(context.Background(), tt.req) + assert.EqualValues(t, tt.want, resp) + assert.EqualValues(t, tt.err, err) + }) + } +} + +type mockProject struct { + GetProjectFunc func(ctx context.Context, id string) (model.Project, error) + CreateProjectFunc func(ctx context.Context, project model.Project) (model.Project, error) + ListProjectFunc func(ctx context.Context) ([]model.Project, error) + UpdateProjectFunc func(ctx context.Context, toUpdate model.Project) (model.Project, error) +} + +func (m mockProject) List(ctx context.Context) ([]model.Project, error) { + return m.ListProjectFunc(ctx) +} + +func (m mockProject) Create(ctx context.Context, project model.Project) (model.Project, error) { + return m.CreateProjectFunc(ctx, project) +} + +func (m mockProject) Get(ctx context.Context, id string) (model.Project, error) { + return m.GetProjectFunc(ctx, id) +} + +func (m mockProject) Update(ctx context.Context, toUpdate model.Project) (model.Project, error) { + return m.UpdateProjectFunc(ctx, toUpdate) +} diff --git a/api/handler/v1/util.go b/api/handler/v1/util.go index 460be25af..206d4e1d5 100644 --- a/api/handler/v1/util.go +++ b/api/handler/v1/util.go @@ -3,6 +3,17 @@ package v1 import ( "fmt" "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// HTTP Codes defined here: +// https://github.com/grpc-ecosystem/grpc-gateway/blob/master/runtime/errors.go#L36 + +var ( + grpcInternalServerError = status.Errorf(codes.Internal, internalServerError.Error()) + grpcBadBodyError = status.Error(codes.InvalidArgument, badRequestError.Error()) ) func mapOfStringValues(m map[string]interface{}) (map[string]string, error) { diff --git a/api/handler/v1/v1.go b/api/handler/v1/v1.go index 35762d8f0..7a0676885 100644 --- a/api/handler/v1/v1.go +++ b/api/handler/v1/v1.go @@ -11,7 +11,8 @@ import ( type Dep struct { shieldv1.UnimplementedShieldServiceServer - OrgService OrganizationService + OrgService OrganizationService + ProjectService ProjectService } var ( diff --git a/cmd/serve_api.go b/cmd/serve_api.go index ce3da5ef9..6536a1995 100644 --- a/cmd/serve_api.go +++ b/cmd/serve_api.go @@ -8,6 +8,7 @@ import ( v1 "github.com/odpf/shield/api/handler/v1" "github.com/odpf/shield/config" "github.com/odpf/shield/internal/org" + "github.com/odpf/shield/internal/project" "github.com/odpf/shield/store/postgres" "github.com/odpf/salt/log" @@ -44,6 +45,9 @@ func apiCommand(logger log.Logger, appConfig *config.Shield) *cli.Command { OrgService: org.Service{ Store: postgres.NewStore(db), }, + ProjectService: project.Service{ + Store: postgres.NewStore(db), + }, }, }) diff --git a/internal/org/org.go b/internal/org/org.go index e5934a71d..82b24adcf 100644 --- a/internal/org/org.go +++ b/internal/org/org.go @@ -3,17 +3,9 @@ package org import ( "context" "errors" - "time" -) -type Organization struct { - Id string - Name string - Slug string - Metadata map[string]string - CreatedAt time.Time - UpdatedAt time.Time -} + "github.com/odpf/shield/model" +) type Service struct { Store Store @@ -25,34 +17,34 @@ var ( ) type Store interface { - GetOrg(ctx context.Context, id string) (Organization, error) - CreateOrg(ctx context.Context, org Organization) (Organization, error) - ListOrg(ctx context.Context) ([]Organization, error) - UpdateOrg(ctx context.Context, toUpdate Organization) (Organization, error) + GetOrg(ctx context.Context, id string) (model.Organization, error) + CreateOrg(ctx context.Context, org model.Organization) (model.Organization, error) + ListOrg(ctx context.Context) ([]model.Organization, error) + UpdateOrg(ctx context.Context, toUpdate model.Organization) (model.Organization, error) } -func (s Service) GetOrganization(ctx context.Context, id string) (Organization, error) { +func (s Service) Get(ctx context.Context, id string) (model.Organization, error) { return s.Store.GetOrg(ctx, id) } -func (s Service) CreateOrganization(ctx context.Context, org Organization) (Organization, error) { - newOrg, err := s.Store.CreateOrg(ctx, Organization{ +func (s Service) Create(ctx context.Context, org model.Organization) (model.Organization, error) { + newOrg, err := s.Store.CreateOrg(ctx, model.Organization{ Name: org.Name, Slug: org.Slug, Metadata: org.Metadata, }) if err != nil { - return Organization{}, err + return model.Organization{}, err } return newOrg, nil } -func (s Service) ListOrganizations(ctx context.Context) ([]Organization, error) { +func (s Service) List(ctx context.Context) ([]model.Organization, error) { return s.Store.ListOrg(ctx) } -func (s Service) UpdateOrganization(ctx context.Context, toUpdate Organization) (Organization, error) { +func (s Service) Update(ctx context.Context, toUpdate model.Organization) (model.Organization, error) { return s.Store.UpdateOrg(ctx, toUpdate) } diff --git a/internal/project/project.go b/internal/project/project.go new file mode 100644 index 000000000..46878da3c --- /dev/null +++ b/internal/project/project.go @@ -0,0 +1,51 @@ +package project + +import ( + "context" + "errors" + + "github.com/odpf/shield/model" +) + +type Service struct { + Store Store +} + +var ( + ProjectDoesntExist = errors.New("project doesn't exist") + InvalidUUID = errors.New("invalid syntax of uuid") +) + +type Store interface { + GetProject(ctx context.Context, id string) (model.Project, error) + CreateProject(ctx context.Context, org model.Project) (model.Project, error) + ListProject(ctx context.Context) ([]model.Project, error) + UpdateProject(ctx context.Context, toUpdate model.Project) (model.Project, error) +} + +func (s Service) Get(ctx context.Context, id string) (model.Project, error) { + return s.Store.GetProject(ctx, id) +} + +func (s Service) Create(ctx context.Context, project model.Project) (model.Project, error) { + newOrg, err := s.Store.CreateProject(ctx, model.Project{ + Name: project.Name, + Slug: project.Slug, + Metadata: project.Metadata, + Organization: project.Organization, + }) + + if err != nil { + return model.Project{}, err + } + + return newOrg, nil +} + +func (s Service) List(ctx context.Context) ([]model.Project, error) { + return s.Store.ListProject(ctx) +} + +func (s Service) Update(ctx context.Context, toUpdate model.Project) (model.Project, error) { + return s.Store.UpdateProject(ctx, toUpdate) +} diff --git a/model/model.go b/model/model.go new file mode 100644 index 000000000..2cc3b3c58 --- /dev/null +++ b/model/model.go @@ -0,0 +1,22 @@ +package model + +import "time" + +type Project struct { + Id string + Name string + Slug string + Organization Organization + Metadata map[string]string + CreatedAt time.Time + UpdatedAt time.Time +} + +type Organization struct { + Id string + Name string + Slug string + Metadata map[string]string + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/store/postgres/migrations/20211020192111_create_organization_table.up.sql b/store/postgres/migrations/20211020192111_create_organization_table.up.sql index 20d40079d..0c7b77665 100644 --- a/store/postgres/migrations/20211020192111_create_organization_table.up.sql +++ b/store/postgres/migrations/20211020192111_create_organization_table.up.sql @@ -4,7 +4,6 @@ CREATE TABLE IF NOT EXISTS organizations name varchar UNIQUE NOT NULL, slug varchar UNIQUE NOT NULL, metadata jsonb, - version int NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW(), deleted_at timestamptz diff --git a/store/postgres/migrations/20211028230242_create_projects_table.down.sql b/store/postgres/migrations/20211028230242_create_projects_table.down.sql new file mode 100644 index 000000000..bb6a95ca2 --- /dev/null +++ b/store/postgres/migrations/20211028230242_create_projects_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS projects ; diff --git a/store/postgres/migrations/20211028230242_create_projects_table.up.sql b/store/postgres/migrations/20211028230242_create_projects_table.up.sql new file mode 100644 index 000000000..feb7d5503 --- /dev/null +++ b/store/postgres/migrations/20211028230242_create_projects_table.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS projects +( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + name varchar UNIQUE NOT NULL, + slug varchar UNIQUE NOT NULL, + org_id uuid NOT NULL REFERENCES organizations(id), + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + deleted_at timestamptz +); diff --git a/store/postgres/org.go b/store/postgres/org.go index 3b03a8631..a7cc52248 100644 --- a/store/postgres/org.go +++ b/store/postgres/org.go @@ -9,8 +9,7 @@ import ( "time" "github.com/odpf/shield/internal/org" - - "github.com/jmoiron/sqlx" + "github.com/odpf/shield/model" ) type Organization struct { @@ -18,57 +17,45 @@ type Organization struct { Name string `db:"name"` Slug string `db:"slug"` Metadata []byte `db:"metadata"` - Version int `db:"version"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } const ( - getOrganizationsQuery = `SELECT id, name, slug, metadata, created_at, updated_at from organizations where id=$1;` - createOrganizationQuery = `INSERT INTO organizations(name, slug, metadata) values($1, $2, $3) RETURNING id, name, slug, metadata, created_at, updated_at;` - listOrganizationsQuery = `SELECT id, name, slug, metadata, created_at, updated_at from organizations;` - selectOrganizationForUpdateQuery = `SELECT id, name, slug, metadata, version, updated_at from organizations where id=$1;` - updateOrganizationQuery = `UPDATE organizations set name = $2, slug = $3, metadata = $4, updated_at = now() where id = $1 RETURNING id, name, slug, metadata, created_at, updated_at;` + getOrganizationsQuery = `SELECT id, name, slug, metadata, created_at, updated_at from organizations where id=$1;` + createOrganizationQuery = `INSERT INTO organizations(name, slug, metadata) values($1, $2, $3) RETURNING id, name, slug, metadata, created_at, updated_at;` + listOrganizationsQuery = `SELECT id, name, slug, metadata, created_at, updated_at from organizations;` + updateOrganizationQuery = `UPDATE organizations set name = $2, slug = $3, metadata = $4, updated_at = now() where id = $1 RETURNING id, name, slug, metadata, created_at, updated_at;` ) -func (s Store) GetOrg(ctx context.Context, id string) (org.Organization, error) { - fetchedOrg, _, err := s.selectOrg(ctx, id, false, nil) - return fetchedOrg, err -} - -func (s Store) selectOrg(ctx context.Context, id string, forUpdate bool, txn *sqlx.Tx) (org.Organization, int, error) { +func (s Store) GetOrg(ctx context.Context, id string) (model.Organization, error) { var fetchedOrg Organization - err := s.DB.WithTimeout(ctx, func(ctx context.Context) error { - if forUpdate { - return txn.GetContext(ctx, &fetchedOrg, selectOrganizationForUpdateQuery, id) - } else { - return s.DB.GetContext(ctx, &fetchedOrg, getOrganizationsQuery, id) - } + return s.DB.GetContext(ctx, &fetchedOrg, getOrganizationsQuery, id) }) if errors.Is(err, sql.ErrNoRows) { - return org.Organization{}, -1, org.OrgDoesntExist + return model.Organization{}, org.OrgDoesntExist } else if err != nil && fmt.Sprintf("%s", err.Error()[0:38]) == "pq: invalid input syntax for type uuid" { // TODO: this uuid syntax is a error defined in db, not in library // need to look into better ways to implement this - return org.Organization{}, -1, org.InvalidUUID + return model.Organization{}, org.InvalidUUID } else if err != nil { - return org.Organization{}, -1, fmt.Errorf("%w: %s", dbErr, err) + return model.Organization{}, fmt.Errorf("%w: %s", dbErr, err) } transformedOrg, err := transformToOrg(fetchedOrg) if err != nil { - return org.Organization{}, -1, fmt.Errorf("%w: %s", parseErr, err) + return model.Organization{}, fmt.Errorf("%w: %s", parseErr, err) } - return transformedOrg, fetchedOrg.Version, nil + return transformedOrg, nil } -func (s Store) CreateOrg(ctx context.Context, orgToCreate org.Organization) (org.Organization, error) { +func (s Store) CreateOrg(ctx context.Context, orgToCreate model.Organization) (model.Organization, error) { marshaledMetadata, err := json.Marshal(orgToCreate.Metadata) if err != nil { - return org.Organization{}, fmt.Errorf("%w: %s", parseErr, err) + return model.Organization{}, fmt.Errorf("%w: %s", parseErr, err) } var newOrg Organization @@ -77,37 +64,37 @@ func (s Store) CreateOrg(ctx context.Context, orgToCreate org.Organization) (org }) if err != nil { - return org.Organization{}, fmt.Errorf("%w: %s", dbErr, err) + return model.Organization{}, fmt.Errorf("%w: %s", dbErr, err) } transformedOrg, err := transformToOrg(newOrg) if err != nil { - return org.Organization{}, fmt.Errorf("%w: %s", parseErr, err) + return model.Organization{}, fmt.Errorf("%w: %s", parseErr, err) } return transformedOrg, nil } -func (s Store) ListOrg(ctx context.Context) ([]org.Organization, error) { +func (s Store) ListOrg(ctx context.Context) ([]model.Organization, error) { var fetchedOrgs []Organization err := s.DB.WithTimeout(ctx, func(ctx context.Context) error { return s.DB.SelectContext(ctx, &fetchedOrgs, listOrganizationsQuery) }) if errors.Is(err, sql.ErrNoRows) { - return []org.Organization{}, org.OrgDoesntExist + return []model.Organization{}, org.OrgDoesntExist } if err != nil { - return []org.Organization{}, fmt.Errorf("%w: %s", dbErr, err) + return []model.Organization{}, fmt.Errorf("%w: %s", dbErr, err) } - var transformedOrgs []org.Organization + var transformedOrgs []model.Organization for _, o := range fetchedOrgs { transformedOrg, err := transformToOrg(o) if err != nil { - return []org.Organization{}, fmt.Errorf("%w: %s", parseErr, err) + return []model.Organization{}, fmt.Errorf("%w: %s", parseErr, err) } transformedOrgs = append(transformedOrgs, transformedOrg) @@ -116,12 +103,12 @@ func (s Store) ListOrg(ctx context.Context) ([]org.Organization, error) { return transformedOrgs, nil } -func (s Store) UpdateOrg(ctx context.Context, toUpdate org.Organization) (org.Organization, error) { +func (s Store) UpdateOrg(ctx context.Context, toUpdate model.Organization) (model.Organization, error) { var updatedOrg Organization marshaledMetadata, err := json.Marshal(toUpdate.Metadata) if err != nil { - return org.Organization{}, fmt.Errorf("%w: %s", parseErr, err) + return model.Organization{}, fmt.Errorf("%w: %s", parseErr, err) } err = s.DB.WithTimeout(ctx, func(ctx context.Context) error { @@ -129,24 +116,24 @@ func (s Store) UpdateOrg(ctx context.Context, toUpdate org.Organization) (org.Or }) if err != nil { - return org.Organization{}, fmt.Errorf("%s: %w", txnErr, err) + return model.Organization{}, fmt.Errorf("%s: %w", txnErr, err) } toUpdate, err = transformToOrg(updatedOrg) if err != nil { - return org.Organization{}, fmt.Errorf("%s: %w", parseErr, err) + return model.Organization{}, fmt.Errorf("%s: %w", parseErr, err) } return toUpdate, nil } -func transformToOrg(from Organization) (org.Organization, error) { +func transformToOrg(from Organization) (model.Organization, error) { var unmarshalledMetadata map[string]string if err := json.Unmarshal(from.Metadata, &unmarshalledMetadata); err != nil { - return org.Organization{}, err + return model.Organization{}, err } - return org.Organization{ + return model.Organization{ Id: from.Id, Name: from.Name, Slug: from.Slug, diff --git a/store/postgres/projects.go b/store/postgres/projects.go new file mode 100644 index 000000000..ac655d778 --- /dev/null +++ b/store/postgres/projects.go @@ -0,0 +1,155 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/odpf/shield/internal/project" + "github.com/odpf/shield/model" +) + +type Project struct { + Id string `db:"id"` + Name string `db:"name"` + Slug string `db:"slug"` + OrgId string `db:"org_id"` + Metadata []byte `db:"metadata"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +const ( + getProjectsQuery = `SELECT id, name, slug, org_id, metadata, created_at, updated_at from projects where id=$1;` + createProjectQuery = `INSERT INTO projects(name, slug, org_id, metadata) values($1, $2, $3, $4) RETURNING id, name, slug, org_id, metadata, created_at, updated_at;` + listProjectQuery = `SELECT id, name, slug, org_id, metadata, created_at, updated_at from projects;` + updateProjectQuery = `UPDATE projects set name = $2, slug = $3, org_id=$4, metadata = $5, updated_at = now() where id = $1 RETURNING id, name, slug, metadata, created_at, updated_at;` +) + +func (s Store) GetProject(ctx context.Context, id string) (model.Project, error) { + var fetchedProject Project + err := s.DB.WithTimeout(ctx, func(ctx context.Context) error { + return s.DB.GetContext(ctx, &fetchedProject, getProjectsQuery, id) + }) + + if errors.Is(err, sql.ErrNoRows) { + return model.Project{}, project.ProjectDoesntExist + } else if err != nil && fmt.Sprintf("%s", err.Error()[0:38]) == "pq: invalid input syntax for type uuid" { + // TODO: this uuid syntax is a error defined in db, not in library + // need to look into better ways to implement this + return model.Project{}, project.InvalidUUID + } else if err != nil { + return model.Project{}, fmt.Errorf("%w: %s", dbErr, err) + } + + if err != nil { + return model.Project{}, err + } + + transformedProject, err := transformToProject(fetchedProject) + if err != nil { + return model.Project{}, err + } + + return transformedProject, nil +} + +func (s Store) CreateProject(ctx context.Context, projectToCreate model.Project) (model.Project, error) { + marshaledMetadata, err := json.Marshal(projectToCreate.Metadata) + if err != nil { + return model.Project{}, fmt.Errorf("%w: %s", parseErr, err) + } + + var newProject Project + err = s.DB.WithTimeout(ctx, func(ctx context.Context) error { + return s.DB.GetContext(ctx, &newProject, createProjectQuery, projectToCreate.Name, projectToCreate.Slug, projectToCreate.Organization.Id, marshaledMetadata) + }) + + if err != nil { + return model.Project{}, fmt.Errorf("%w: %s", dbErr, err) + } + + transformedOrg, err := transformToProject(newProject) + if err != nil { + return model.Project{}, fmt.Errorf("%w: %s", parseErr, err) + } + + return transformedOrg, nil +} + +func (s Store) ListProject(ctx context.Context) ([]model.Project, error) { + var fetchedProjects []Project + err := s.DB.WithTimeout(ctx, func(ctx context.Context) error { + return s.DB.SelectContext(ctx, &fetchedProjects, listProjectQuery) + }) + + if errors.Is(err, sql.ErrNoRows) { + return []model.Project{}, project.ProjectDoesntExist + } + + if err != nil { + return []model.Project{}, fmt.Errorf("%w: %s", dbErr, err) + } + + var transformedProjects []model.Project + + for _, o := range fetchedProjects { + transformedOrg, err := transformToProject(o) + if err != nil { + return []model.Project{}, fmt.Errorf("%w: %s", parseErr, err) + } + + transformedProjects = append(transformedProjects, transformedOrg) + } + + return transformedProjects, nil +} + +func (s Store) UpdateProject(ctx context.Context, toUpdate model.Project) (model.Project, error) { + var updatedProject Project + + marshaledMetadata, err := json.Marshal(toUpdate.Metadata) + if err != nil { + return model.Project{}, fmt.Errorf("%w: %s", parseErr, err) + } + + err = s.DB.WithTimeout(ctx, func(ctx context.Context) error { + return s.DB.GetContext(ctx, &updatedProject, updateProjectQuery, toUpdate.Id, toUpdate.Name, toUpdate.Slug, toUpdate.Organization.Id, marshaledMetadata) + }) + + if errors.Is(err, sql.ErrNoRows) { + return model.Project{}, project.ProjectDoesntExist + } else if err != nil && fmt.Sprintf("%s", err.Error()[0:38]) == "pq: invalid input syntax for type uuid" { + // TODO: this uuid syntax is a error defined in db, not in library + // need to look into better ways to implement this + return model.Project{}, fmt.Errorf("%w: %s", project.InvalidUUID, err) + } else if err != nil { + return model.Project{}, fmt.Errorf("%w: %s", dbErr, err) + } + + toUpdate, err = transformToProject(updatedProject) + if err != nil { + return model.Project{}, fmt.Errorf("%s: %w", parseErr, err) + } + + return toUpdate, nil +} + +func transformToProject(from Project) (model.Project, error) { + var unmarshalledMetadata map[string]string + if err := json.Unmarshal(from.Metadata, &unmarshalledMetadata); err != nil { + return model.Project{}, err + } + + return model.Project{ + Id: from.Id, + Name: from.Name, + Slug: from.Slug, + Metadata: unmarshalledMetadata, + CreatedAt: from.CreatedAt, + UpdatedAt: from.UpdatedAt, + }, nil +}