Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Frontend Build Out #41

Merged
merged 9 commits into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/server/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ go_library(
"//oapierr",
"//openapi:pacta_generated",
"//secrets",
"//session",
"//task",
"@com_github_azure_azure_sdk_for_go_sdk_azcore//:azcore",
"@com_github_azure_azure_sdk_for_go_sdk_azidentity//:azidentity",
Expand Down
4 changes: 3 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/RMI/pacta/oapierr"
oapipacta "github.com/RMI/pacta/openapi/pacta"
"github.com/RMI/pacta/secrets"
"github.com/RMI/pacta/session"
"github.com/RMI/pacta/task"
"github.com/Silicon-Ally/cryptorand"
"github.com/Silicon-Ally/zaphttplog"
Expand Down Expand Up @@ -312,11 +313,12 @@ func run(args []string) error {
// LogEntry created by the logging middleware.
chimiddleware.RequestID,
chimiddleware.RealIP,
zaphttplog.NewMiddleware(logger),
zaphttplog.NewMiddleware(logger, zaphttplog.WithConcise(true)),
chimiddleware.Recoverer,

jwtauth.Verifier(jwtauth.New("EdDSA", nil, jwKey)),
jwtauth.Authenticator,
session.WithAuthn(logger, db),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nothing needs to change here, but because auth in RMI-land is so different than AS-land, we really don't need a whole package and middleware for this, it could just be a server helper like s.getUser or s.getUserID. It's no more or less code in the endpoints themselves, just the difference between session.UserID(ctx) and s.getUserID(ctx).


oapimiddleware.OapiRequestValidator(pactaSwagger),

Expand Down
3 changes: 3 additions & 0 deletions cmd/server/pactasrv/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ go_library(
name = "pactasrv",
srcs = [
"initiative.go",
"initiative_invitation.go",
"initiative_user_relationship.go",
"pacta_version.go",
"pactasrv.go",
"portfolio.go",
Expand All @@ -18,6 +20,7 @@ go_library(
"//oapierr",
"//openapi:pacta_generated",
"//pacta",
"//session",
"//task",
"@com_github_go_chi_jwtauth_v5//:jwtauth",
"@com_github_google_uuid//:uuid",
Expand Down
16 changes: 16 additions & 0 deletions cmd/server/pactasrv/conv/oapi_to_pacta.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ func PactaVersionCreateFromOAPI(p *api.PactaVersionCreate) (*pacta.PACTAVersion,
}, nil
}

func InitiativeInvitationFromOAPI(i *api.InitiativeInvitationCreate) (*pacta.InitiativeInvitation, error) {
if i == nil {
return nil, oapierr.Internal("initiativeInvitationToOAPI: can't convert nil pointer")
}
if !initiativeIDRegex.MatchString(i.Id) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't remember if I asked this before, but why are we validating IDs, aren't we the ones generating them? And if we aren't the ones generating them, let's chat.

return nil, oapierr.BadRequest("id must contain only alphanumeric characters, underscores, and dashes")
}
if i.InitiativeId == "" {
return nil, oapierr.BadRequest("initiative_id must not be empty")
}
return &pacta.InitiativeInvitation{
ID: pacta.InitiativeInvitationID(i.Id),
Initiative: &pacta.Initiative{ID: pacta.InitiativeID(i.InitiativeId)},
}, nil
}

func ifNil[T any](t *T, fallback T) T {
if t == nil {
return fallback
Expand Down
40 changes: 40 additions & 0 deletions cmd/server/pactasrv/conv/pacta_to_oapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,46 @@ func PactaVersionToOAPI(pv *pacta.PACTAVersion) (*api.PactaVersion, error) {
}, nil
}

func InitiativeInvitationToOAPI(i *pacta.InitiativeInvitation) (*api.InitiativeInvitation, error) {
if i == nil {
return nil, oapierr.Internal("initiativeToOAPI: can't convert nil pointer")
}
var usedAt *string
if !i.UsedAt.IsZero() {
usedAt = ptr(i.UsedAt.String())
}
var usedBy *string
if i.UsedBy != nil {
usedBy = ptr(string(i.UsedBy.ID))
}
Comment on lines +66 to +68
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you expect to be doing this a lot, you could make a generic nullablePtr or similar helper, then inline this in the return

return &api.InitiativeInvitation{
CreatedAt: i.CreatedAt,
Id: string(i.ID),
InitiativeId: string(i.Initiative.ID),
UsedAt: usedAt,
UsedByUserId: usedBy,
}, nil
}

func InitiativeUserRelationshipToOAPI(i *pacta.InitiativeUserRelationship) (*api.InitiativeUserRelationship, error) {
if i == nil {
return nil, oapierr.Internal("initiativeUserRelationshipToOAPI: can't convert nil pointer")
}
if i.User == nil {
return nil, oapierr.Internal("initiativeUserRelationshipToOAPI: can't convert nil user")
}
if i.Initiative == nil {
return nil, oapierr.Internal("initiativeUserRelationshipToOAPI: can't convert nil initiative")
}
return &api.InitiativeUserRelationship{
UpdatedAt: i.UpdatedAt,
InitiativeId: string(i.Initiative.ID),
UserId: string(i.User.ID),
Manager: i.Manager,
Member: i.Member,
}, nil
}

func ptr[T any](t T) *T {
return &t
}
123 changes: 123 additions & 0 deletions cmd/server/pactasrv/initiative_invitation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package pactasrv

import (
"context"
"fmt"
"time"

"github.com/RMI/pacta/cmd/server/pactasrv/conv"
"github.com/RMI/pacta/db"
"github.com/RMI/pacta/oapierr"
api "github.com/RMI/pacta/openapi/pacta"
"github.com/RMI/pacta/pacta"
"go.uber.org/zap"
)

// Creates an initiative invitation
// (POST /initiative-invitation)
func (s *Server) CreateInitiativeInvitation(ctx context.Context, request api.CreateInitiativeInvitationRequestObject) (api.CreateInitiativeInvitationResponseObject, error) {
// TODO(#12) Implement Authorization
ii, err := conv.InitiativeInvitationFromOAPI(request.Body)
if err != nil {
return nil, err
}
id, err := s.DB.CreateInitiativeInvitation(s.DB.NoTxn(ctx), ii)
if err != nil {
return nil, oapierr.Internal("failed to create initiative invitation", zap.Error(err))
}
if id != ii.ID {
return nil, oapierr.Internal(
"failed to create initiative invitation: ID mismatch",
zap.String("requested_id", string(ii.ID)),
zap.String("actual_id", string(id)),
)
}
return api.CreateInitiativeInvitation204Response{}, nil
}

// Deletes an initiative invitation by id
// (DELETE /initiative-invitation/{id})
func (s *Server) DeleteInitiativeInvitation(ctx context.Context, request api.DeleteInitiativeInvitationRequestObject) (api.DeleteInitiativeInvitationResponseObject, error) {
// TODO(#12) Implement Authorization
err := s.DB.DeleteInitiativeInvitation(s.DB.NoTxn(ctx), pacta.InitiativeInvitationID(request.Id))
if err != nil {
return nil, oapierr.Internal("failed to delete initiative invitation", zap.Error(err))
}
return api.DeleteInitiativeInvitation204Response{}, nil
}

// Returns the initiative invitation from this id, if it exists
// (GET /initiative-invitation/{id})
func (s *Server) GetInitiativeInvitation(ctx context.Context, request api.GetInitiativeInvitationRequestObject) (api.GetInitiativeInvitationResponseObject, error) {
// TODO(#12) Implement Authorization
ii, err := s.DB.InitiativeInvitation(s.DB.NoTxn(ctx), pacta.InitiativeInvitationID(request.Id))
if err != nil {
return nil, oapierr.Internal("failed to retrieve initiative invitation", zap.Error(err))
}
result, err := conv.InitiativeInvitationToOAPI(ii)
if err != nil {
return nil, err
}
return api.GetInitiativeInvitation200JSONResponse(*result), nil
}

// Claims this initiative invitation, if it exists
// (POST /initiative-invitation/{id}:claim)
func (s *Server) ClaimInitiativeInvitation(ctx context.Context, request api.ClaimInitiativeInvitationRequestObject) (api.ClaimInitiativeInvitationResponseObject, error) {
userID, err := getUserID(ctx)
if err != nil {
return nil, err
}
var customErr api.ClaimInitiativeInvitationResponseObject
err = s.DB.Transactional(ctx, func(tx db.Tx) error {
ii, err := s.DB.InitiativeInvitation(tx, pacta.InitiativeInvitationID(request.Id))
if err != nil {
return fmt.Errorf("looking up initiative invite: %w", err)
}
if !ii.UsedAt.IsZero() || ii.UsedBy != nil {
if ii.UsedBy != nil && ii.UsedBy.ID == userID {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My usual grumbling about new paradigms I don't like: the whole "We use objects for everything and only populate the IDs" makes all of this logic one conditional comparision more than it otherwise would need to be. E.g. as compared to

if ii.UsedByID == userID { ... }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think in most cases you'll come to like this pattern. I've found it increasingly lovely for side projects.

// We don't return an error if the same user tries to claim the same invitation twice,
// which might happen by accident, but wouldn't impact the state of the initiative memberships.
// We may want to log this, though.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd log at WARN

return nil
} else {
customErr = api.ClaimInitiativeInvitation409Response{}
return fmt.Errorf("initiative is already used: %+v", ii)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be using oapierr.BadRequest (or similar) for this. For this case in particular, we likely want a custom error code so we can show a specific error to the end user. Then you'd just need to update the error logic below to:

if errors.Is(err, oapierr.Error{}) { // or something like that, could also make oapierr.Is(err) or similar
	return err
} else if err != nil {
	return oapierr.Internal(...)
}

You could even make a lil helper, like return oapierr.OrInternal(err)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, but through different means, as an external var.

}
}
err = s.DB.UpdateInitiativeInvitation(tx, ii.ID,
db.SetInitiativeInvitationUsedAt(time.Now()),
db.SetInitiativeInvitationUsedBy(userID))
if err != nil {
return fmt.Errorf("updating initiative invite: %w", err)
}
err = s.DB.UpdateInitiativeUserRelationship(tx, ii.Initiative.ID, userID,
db.SetInitiativeUserRelationshipMember(true))
if err != nil {
return fmt.Errorf("creating initiative membership: %w", err)
}
return nil
})
if err != nil {
if customErr != nil {
return customErr, nil
}
return nil, oapierr.Internal("failed to claim initiative invitation", zap.Error(err))
}
return api.ClaimInitiativeInvitation204Response{}, nil
}

// Returns all initiative invitations associated with the initiative
// (GET /initiative/{id}/invitations)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this GET /initiative/{id}/invitations and not GET /initiative-invitation? I think the standard RESTful approach would be the latter, ex

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is getting the invitations associated with an initiative. I think this is the standard paradigm: resources that have a nested/ownership structure can be requested this way. The ID in this case is the initiative ID.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on this, I'd just roll "listing invitations" into GET /initiative/{id}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We won't want that for public access. I think keeping them distinct will make authz much easier.

func (s *Server) ListInitiativeInvitations(ctx context.Context, request api.ListInitiativeInvitationsRequestObject) (api.ListInitiativeInvitationsResponseObject, error) {
// TODO(#12) Implement Authorization
iis, err := s.DB.InitiativeInvitationsByInitiative(s.DB.NoTxn(ctx), pacta.InitiativeID(request.Id))
if err != nil {
return nil, oapierr.Internal("failed to list initiative invitations", zap.Error(err))
}
result, err := dereference(mapAll(iis, conv.InitiativeInvitationToOAPI))
if err != nil {
return nil, err
}
return api.ListInitiativeInvitations200JSONResponse(result), nil
}
75 changes: 75 additions & 0 deletions cmd/server/pactasrv/initiative_user_relationship.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package pactasrv

import (
"context"

"github.com/RMI/pacta/cmd/server/pactasrv/conv"
"github.com/RMI/pacta/db"
"github.com/RMI/pacta/oapierr"
api "github.com/RMI/pacta/openapi/pacta"
"github.com/RMI/pacta/pacta"
"go.uber.org/zap"
)

// Returns all initiative user relationships for the user that the user has access to view
// (GET /initiative/{id}/user-relationships)
func (s *Server) ListInitiativeUserRelationshipsByUser(ctx context.Context, request api.ListInitiativeUserRelationshipsByUserRequestObject) (api.ListInitiativeUserRelationshipsByUserResponseObject, error) {
// TODO(#12) Implement Authorization
iurs, err := s.DB.InitiativeUserRelationshipsByUser(s.DB.NoTxn(ctx), pacta.UserID(request.UserId))
if err != nil {
return nil, oapierr.Internal("failed to retrieve initiative user relationships by user", zap.Error(err))
}
result, err := dereference(mapAll(iurs, conv.InitiativeUserRelationshipToOAPI))
if err != nil {
return nil, err
}
return api.ListInitiativeUserRelationshipsByUser200JSONResponse(result), nil
}

// Returns all initiative user relationships for the initiative that the user has access to view
// (GET /initiative/user-relationships/{id})
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This URL scheme is not intuitive to me, e.g. the difference between:

  • GET /initiative/{id}/user-relationships, and
  • GET /initiative/user-relationships/{id}, and

The benefit of RESTful stuff is that usually it's pretty obvious from the URL what the endpoint does, but I'm not getting that from these. How about something like:

  • GET /initiative/{id} - Gets the initiative and the relationships, can optionally hide the latter behind a query param like withRelationships=true
    • If for some reason we're not frequently loading initiatives + their relationships at the same time, could also do /initiative/{id}/relationships, basically like it is now.
  • GET /user/{id}/initiatives - Gets a list of initiatives the user has access to

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If for some reason we're not frequently loading initiatives + their relationships at the same time, could also do /initiative/{id}/relationships, basically like it is now.

Yep! Public access won't load relationships.

I think this actually fits nicely into the "composite key exception" listed here

func (s *Server) ListInitiativeUserRelationshipsByInitiative(ctx context.Context, request api.ListInitiativeUserRelationshipsByInitiativeRequestObject) (api.ListInitiativeUserRelationshipsByInitiativeResponseObject, error) {
// TODO(#12) Implement Authorization
iurs, err := s.DB.InitiativeUserRelationshipsByInitiatives(s.DB.NoTxn(ctx), pacta.InitiativeID(request.InitiativeId))
if err != nil {
return nil, oapierr.Internal("failed to retrieve initiative user relationships by user", zap.Error(err))
}
result, err := dereference(mapAll(iurs, conv.InitiativeUserRelationshipToOAPI))
if err != nil {
return nil, err
}
return api.ListInitiativeUserRelationshipsByInitiative200JSONResponse(result), nil
}

// Returns the initiative user relationship from this id, if it exists
// (GET /initiative/{initiativeId}/user-relationship/{userId})
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API surface seems really broad to me, what's the motivation for having three different endpoints that return a user's relationship to an initiative? Unless we expect the list of relationships to be exceptionally large (>1,000), I'd just let the client filter for the stuff they're looking for.

More API surface means more stuff to secure, and more DB functions/tests/code to maintain overall.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, they will be large. There is a public initiative, which anyone will have the ability to join, and may be segmented by region (i.e. can't be handled as a standalone case).

func (s *Server) GetInitiativeUserRelationship(ctx context.Context, request api.GetInitiativeUserRelationshipRequestObject) (api.GetInitiativeUserRelationshipResponseObject, error) {
// TODO(#12) Implement Authorization
iur, err := s.DB.InitiativeUserRelationship(s.DB.NoTxn(ctx), pacta.InitiativeID(request.InitiativeId), pacta.UserID(request.UserId))
if err != nil {
return nil, oapierr.Internal("failed to retrieve initiative user relationship", zap.Error(err))
}
result, err := conv.InitiativeUserRelationshipToOAPI(iur)
if err != nil {
return nil, err
}
return api.GetInitiativeUserRelationship200JSONResponse(*result), nil
}

// Updates initiative user relationship properties
// (PATCH /initiative/{initiativeId}/user-relationship/{userId})
func (s *Server) UpdateInitiativeUserRelationship(ctx context.Context, request api.UpdateInitiativeUserRelationshipRequestObject) (api.UpdateInitiativeUserRelationshipResponseObject, error) {
// TODO(#12) Implement Authorization
mutations := []db.UpdateInitiativeUserRelationshipFn{}
if request.Body.Manager != nil {
mutations = append(mutations, db.SetInitiativeUserRelationshipManager(*request.Body.Manager))
}
if request.Body.Member != nil {
mutations = append(mutations, db.SetInitiativeUserRelationshipMember(*request.Body.Member))
}
err := s.DB.UpdateInitiativeUserRelationship(s.DB.NoTxn(ctx), pacta.InitiativeID(request.InitiativeId), pacta.UserID(request.UserId), mutations...)
if err != nil {
return nil, oapierr.Internal("failed to update initiative user relationship", zap.Error(err))
}
return api.UpdateInitiativeUserRelationship204Response{}, nil
}
19 changes: 14 additions & 5 deletions cmd/server/pactasrv/pactasrv.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/RMI/pacta/db"
"github.com/RMI/pacta/oapierr"
"github.com/RMI/pacta/pacta"
"github.com/RMI/pacta/session"
"github.com/RMI/pacta/task"
"go.uber.org/zap"
)
Expand Down Expand Up @@ -44,6 +45,7 @@ type DB interface {
InitiativeUserRelationshipsByUser(tx db.Tx, uid pacta.UserID) ([]*pacta.InitiativeUserRelationship, error)
InitiativeUserRelationshipsByInitiatives(tx db.Tx, iid pacta.InitiativeID) ([]*pacta.InitiativeUserRelationship, error)
PutInitiativeUserRelationship(tx db.Tx, iur *pacta.InitiativeUserRelationship) error
UpdateInitiativeUserRelationship(tx db.Tx, iid pacta.InitiativeID, uid pacta.UserID, mutations ...db.UpdateInitiativeUserRelationshipFn) error

Initiative(tx db.Tx, id pacta.InitiativeID) (*pacta.Initiative, error)
Initiatives(tx db.Tx, ids []pacta.InitiativeID) (map[pacta.InitiativeID]*pacta.Initiative, error)
Expand All @@ -65,7 +67,7 @@ type DB interface {
CreatePortfolioInitiativeMembership(tx db.Tx, pim *pacta.PortfolioInitiativeMembership) error
DeletePortfolioInitiativeMembership(tx db.Tx, pid pacta.PortfolioID, iid pacta.InitiativeID) error

GetOrCreateUserByAuthn(tx db.Tx, authnMechanism pacta.AuthnMechanism, authnID, enteredEmail, canonicalEmail string) (*pacta.User, error)
GetOrCreateUserByAuthn(tx db.Tx, mech pacta.AuthnMechanism, authnID, email, canonicalEmail string) (*pacta.User, error)
User(tx db.Tx, id pacta.UserID) (*pacta.User, error)
Users(tx db.Tx, ids []pacta.UserID) (map[pacta.UserID]*pacta.User, error)
UpdateUser(tx db.Tx, id pacta.UserID, mutations ...db.UpdateUserFn) error
Expand All @@ -82,10 +84,9 @@ type Blob interface {
}

type Server struct {
DB DB
TaskRunner TaskRunner
Logger *zap.Logger

DB DB
TaskRunner TaskRunner
Logger *zap.Logger
Blob Blob
PorfolioUploadURI string
}
Expand Down Expand Up @@ -115,3 +116,11 @@ func dereference[T any](ts []*T, e error) ([]T, error) {
}
return result, nil
}

func getUserID(ctx context.Context) (pacta.UserID, error) {
userID, err := session.UserIDFromContext(ctx)
if err != nil {
return "", oapierr.Unauthorized("error getting authorization token", zap.Error(err))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return "", oapierr.Unauthorized("error getting authorization token", zap.Error(err))
return "", oapierr.Unauthorized("error loading user ID", zap.Error(err))

}
return pacta.UserID(userID), nil
}
Loading