Reorganize the webUI and API code

Michael Muré created

Included in the changes:
- create a new /api root package to hold all API code, migrate /graphql in there
- git API handlers all use the cache instead of the repo directly
- git API handlers are now tested
- git API handlers now require a "repo" mux parameter
- lots of untangling of API/handlers/middleware
- less code in commands/webui.go

Change summary

README.md                                      |   2 
api/auth/context.go                            |  28 +++
api/auth/errors.go                             |   2 
api/auth/middleware.go                         |  16 +
api/graphql/connections/connection_template.go |   2 
api/graphql/connections/connections.go         |   0 
api/graphql/connections/edges.go               |   0 
api/graphql/connections/gen_comment.go         |   2 
api/graphql/connections/gen_identity.go        |   2 
api/graphql/connections/gen_label.go           |   2 
api/graphql/connections/gen_lazy_bug.go        |   2 
api/graphql/connections/gen_lazy_identity.go   |   2 
api/graphql/connections/gen_operation.go       |   2 
api/graphql/connections/gen_timeline.go        |   2 
api/graphql/gen_graphql.go                     |   0 
api/graphql/gqlgen.yml                         |   0 
api/graphql/graph/gen_graph.go                 |   7 
api/graphql/graphql_test.go                    |  17 +
api/graphql/handler.go                         |  32 +++
api/graphql/models/edges.go                    |   0 
api/graphql/models/gen_models.go               |   0 
api/graphql/models/lazy_bug.go                 |   0 
api/graphql/models/lazy_identity.go            |   0 
api/graphql/models/models.go                   |   0 
api/graphql/resolvers/bug.go                   |   6 
api/graphql/resolvers/color.go                 |   2 
api/graphql/resolvers/comment.go               |   4 
api/graphql/resolvers/identity.go              |   4 
api/graphql/resolvers/label.go                 |   4 
api/graphql/resolvers/mutation.go              |  34 +-
api/graphql/resolvers/operations.go            |   4 
api/graphql/resolvers/query.go                 |   4 
api/graphql/resolvers/repo.go                  |  12 
api/graphql/resolvers/root.go                  |  12 
api/graphql/resolvers/timeline.go              |   4 
api/graphql/schema/bug.graphql                 |   0 
api/graphql/schema/identity.graphql            |   0 
api/graphql/schema/label.graphql               |   0 
api/graphql/schema/mutations.graphql           |   0 
api/graphql/schema/operations.graphql          |   0 
api/graphql/schema/repository.graphql          |   0 
api/graphql/schema/root.graphql                |   0 
api/graphql/schema/timeline.graphql            |   0 
api/graphql/schema/types.graphql               |   0 
api/http/git_file_handler.go                   |  61 ++++++
api/http/git_file_handlers_test.go             |  91 ++++++++++
api/http/git_file_upload_handler.go            | 104 +++++++++++
cache/multi_repo_cache.go                      |  16 
cache/repo_cache.go                            |  10 +
commands/webui.go                              | 176 ++-----------------
graphql/graphqlidentity/graphqlidentity.go     |  41 ----
graphql/handler.go                             |  39 ----
webui/codegen.yaml                             |   2 
webui/handler.go                               |  31 +++
54 files changed, 472 insertions(+), 309 deletions(-)

Detailed changes

README.md 🔗

@@ -174,7 +174,7 @@ You can launch a rich Web UI with `git bug webui`.
 
 This web UI is entirely packed inside the same go binary and serve static content through a localhost http server.
 
-The web UI interact with the backend through a GraphQL API. The schema is available [here](graphql/).
+The web UI interact with the backend through a GraphQL API. The schema is available [here](api/graphql/schema).
 
 ## Bridges
 

api/auth/context.go 🔗

@@ -0,0 +1,28 @@
+// Package auth contains helpers for managing identities within the GraphQL API.
+package auth
+
+import (
+	"context"
+
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/entity"
+)
+
+// identityCtxKey is a unique context key, accessible only in this package.
+var identityCtxKey = &struct{}{}
+
+// CtxWithUser attaches an Identity to a context.
+func CtxWithUser(ctx context.Context, userId entity.Id) context.Context {
+	return context.WithValue(ctx, identityCtxKey, userId)
+}
+
+// UserFromCtx retrieves an IdentityCache from the context.
+// If there is no identity in the context, ErrNotAuthenticated is returned.
+// If an error occurs while resolving the identity (e.g. I/O error), then it will be returned.
+func UserFromCtx(ctx context.Context, r *cache.RepoCache) (*cache.IdentityCache, error) {
+	id, ok := ctx.Value(identityCtxKey).(entity.Id)
+	if !ok {
+		return nil, ErrNotAuthenticated
+	}
+	return r.ResolveIdentity(id)
+}

graphql/graphqlidentity/errors.go → api/auth/errors.go 🔗

@@ -1,4 +1,4 @@
-package graphqlidentity
+package auth
 
 import "errors"
 

api/auth/middleware.go 🔗

@@ -0,0 +1,16 @@
+package auth
+
+import (
+	"net/http"
+
+	"github.com/MichaelMure/git-bug/entity"
+)
+
+func Middleware(fixedUserId entity.Id) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			ctx := CtxWithUser(r.Context(), fixedUserId)
+			next.ServeHTTP(w, r.WithContext(ctx))
+		})
+	}
+}

graphql/connections/connection_template.go → api/graphql/connections/connection_template.go 🔗

@@ -5,7 +5,7 @@ import (
 
 	"github.com/cheekybits/genny/generic"
 
-	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 )
 
 // Name define the name of the connection

graphql/connections/gen_comment.go → api/graphql/connections/gen_comment.go 🔗

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // BugCommentEdgeMaker define a function that take a bug.Comment and an offset and

graphql/connections/gen_identity.go → api/graphql/connections/gen_identity.go 🔗

@@ -7,7 +7,7 @@ package connections
 import (
 	"fmt"
 
-	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 )
 
 // ModelsIdentityWrapperEdgeMaker define a function that take a models.IdentityWrapper and an offset and

graphql/connections/gen_label.go → api/graphql/connections/gen_label.go 🔗

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // BugLabelEdgeMaker define a function that take a bug.Label and an offset and

graphql/connections/gen_lazy_bug.go → api/graphql/connections/gen_lazy_bug.go 🔗

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // EntityIdEdgeMaker define a function that take a entity.Id and an offset and

graphql/connections/gen_lazy_identity.go → api/graphql/connections/gen_lazy_identity.go 🔗

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // EntityIdEdgeMaker define a function that take a entity.Id and an offset and

graphql/connections/gen_operation.go → api/graphql/connections/gen_operation.go 🔗

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // BugOperationEdgeMaker define a function that take a bug.Operation and an offset and

graphql/connections/gen_timeline.go → api/graphql/connections/gen_timeline.go 🔗

@@ -7,8 +7,8 @@ package connections
 import (
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 // BugTimelineItemEdgeMaker define a function that take a bug.TimelineItem and an offset and

graphql/graph/gen_graph.go → api/graphql/graph/gen_graph.go 🔗

@@ -15,11 +15,12 @@ import (
 
 	"github.com/99designs/gqlgen/graphql"
 	"github.com/99designs/gqlgen/graphql/introspection"
-	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/models"
-	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/vektah/gqlparser"
 	"github.com/vektah/gqlparser/ast"
+
+	"github.com/MichaelMure/git-bug/api/graphql/models"
+	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/util/git"
 )
 
 // region    ************************** generated!.gotpl **************************

graphql/graphql_test.go → api/graphql/graphql_test.go 🔗

@@ -4,8 +4,11 @@ import (
 	"testing"
 
 	"github.com/99designs/gqlgen/client"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
-	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
+	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/misc/random_bugs"
 	"github.com/MichaelMure/git-bug/repository"
 )
@@ -16,10 +19,11 @@ func TestQueries(t *testing.T) {
 
 	random_bugs.FillRepoWithSeed(repo, 10, 42)
 
-	handler, err := NewHandler(repo)
-	if err != nil {
-		t.Fatal(err)
-	}
+	mrc := cache.NewMultiRepoCache()
+	_, err := mrc.RegisterDefaultRepository(repo)
+	require.NoError(t, err)
+
+	handler := NewHandler(mrc)
 
 	c := client.New(handler)
 
@@ -211,5 +215,6 @@ func TestQueries(t *testing.T) {
 		}
 	}
 
-	c.MustPost(query, &resp)
+	err = c.Post(query, &resp)
+	assert.NoError(t, err)
 }

api/graphql/handler.go 🔗

@@ -0,0 +1,32 @@
+//go:generate go run gen_graphql.go
+
+// Package graphql contains the root GraphQL http handler
+package graphql
+
+import (
+	"io"
+	"net/http"
+
+	"github.com/99designs/gqlgen/graphql/handler"
+
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/resolvers"
+	"github.com/MichaelMure/git-bug/cache"
+)
+
+// Handler is the root GraphQL http handler
+type Handler struct {
+	http.Handler
+	io.Closer
+}
+
+func NewHandler(mrc *cache.MultiRepoCache) Handler {
+	rootResolver := resolvers.NewRootResolver(mrc)
+	config := graph.Config{Resolvers: rootResolver}
+	h := handler.NewDefaultServer(graph.NewExecutableSchema(config))
+
+	return Handler{
+		Handler: h,
+		Closer:  rootResolver,
+	}
+}

graphql/resolvers/bug.go → api/graphql/resolvers/bug.go 🔗

@@ -3,10 +3,10 @@ package resolvers
 import (
 	"context"
 
+	"github.com/MichaelMure/git-bug/api/graphql/connections"
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/connections"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.BugResolver = &bugResolver{}

graphql/resolvers/color.go → api/graphql/resolvers/color.go 🔗

@@ -4,7 +4,7 @@ import (
 	"context"
 	"image/color"
 
-	"github.com/MichaelMure/git-bug/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
 )
 
 var _ graph.ColorResolver = &colorResolver{}

graphql/resolvers/comment.go → api/graphql/resolvers/comment.go 🔗

@@ -3,9 +3,9 @@ package resolvers
 import (
 	"context"
 
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.CommentResolver = &commentResolver{}

graphql/resolvers/identity.go → api/graphql/resolvers/identity.go 🔗

@@ -3,8 +3,8 @@ package resolvers
 import (
 	"context"
 
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 )
 
 var _ graph.IdentityResolver = &identityResolver{}

graphql/resolvers/label.go → api/graphql/resolvers/label.go 🔗

@@ -5,9 +5,9 @@ import (
 	"fmt"
 	"image/color"
 
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.LabelResolver = &labelResolver{}

graphql/resolvers/mutation.go → api/graphql/resolvers/mutation.go 🔗

@@ -4,11 +4,11 @@ import (
 	"context"
 	"time"
 
+	"github.com/MichaelMure/git-bug/api/auth"
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/graphqlidentity"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.MutationResolver = &mutationResolver{}
@@ -31,11 +31,11 @@ func (r mutationResolver) getBug(repoRef *string, bugPrefix string) (*cache.Repo
 		return nil, nil, err
 	}
 
-	bug, err := repo.ResolveBugPrefix(bugPrefix)
+	b, err := repo.ResolveBugPrefix(bugPrefix)
 	if err != nil {
 		return nil, nil, err
 	}
-	return repo, bug, nil
+	return repo, b, nil
 }
 
 func (r mutationResolver) NewBug(ctx context.Context, input models.NewBugInput) (*models.NewBugPayload, error) {
@@ -44,12 +44,12 @@ func (r mutationResolver) NewBug(ctx context.Context, input models.NewBugInput)
 		return nil, err
 	}
 
-	id, err := graphqlidentity.ForContext(ctx, repo)
+	author, err := auth.UserFromCtx(ctx, repo)
 	if err != nil {
 		return nil, err
 	}
 
-	b, op, err := repo.NewBugRaw(id, time.Now().Unix(), input.Title, input.Message, input.Files, nil)
+	b, op, err := repo.NewBugRaw(author, time.Now().Unix(), input.Title, input.Message, input.Files, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -67,12 +67,12 @@ func (r mutationResolver) AddComment(ctx context.Context, input models.AddCommen
 		return nil, err
 	}
 
-	id, err := graphqlidentity.ForContext(ctx, repo)
+	author, err := auth.UserFromCtx(ctx, repo)
 	if err != nil {
 		return nil, err
 	}
 
-	op, err := b.AddCommentRaw(id, time.Now().Unix(), input.Message, input.Files, nil)
+	op, err := b.AddCommentRaw(author, time.Now().Unix(), input.Message, input.Files, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -95,12 +95,12 @@ func (r mutationResolver) ChangeLabels(ctx context.Context, input *models.Change
 		return nil, err
 	}
 
-	id, err := graphqlidentity.ForContext(ctx, repo)
+	author, err := auth.UserFromCtx(ctx, repo)
 	if err != nil {
 		return nil, err
 	}
 
-	results, op, err := b.ChangeLabelsRaw(id, time.Now().Unix(), input.Added, input.Removed, nil)
+	results, op, err := b.ChangeLabelsRaw(author, time.Now().Unix(), input.Added, input.Removed, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -129,12 +129,12 @@ func (r mutationResolver) OpenBug(ctx context.Context, input models.OpenBugInput
 		return nil, err
 	}
 
-	id, err := graphqlidentity.ForContext(ctx, repo)
+	author, err := auth.UserFromCtx(ctx, repo)
 	if err != nil {
 		return nil, err
 	}
 
-	op, err := b.OpenRaw(id, time.Now().Unix(), nil)
+	op, err := b.OpenRaw(author, time.Now().Unix(), nil)
 	if err != nil {
 		return nil, err
 	}
@@ -157,12 +157,12 @@ func (r mutationResolver) CloseBug(ctx context.Context, input models.CloseBugInp
 		return nil, err
 	}
 
-	id, err := graphqlidentity.ForContext(ctx, repo)
+	author, err := auth.UserFromCtx(ctx, repo)
 	if err != nil {
 		return nil, err
 	}
 
-	op, err := b.CloseRaw(id, time.Now().Unix(), nil)
+	op, err := b.CloseRaw(author, time.Now().Unix(), nil)
 	if err != nil {
 		return nil, err
 	}
@@ -185,12 +185,12 @@ func (r mutationResolver) SetTitle(ctx context.Context, input models.SetTitleInp
 		return nil, err
 	}
 
-	id, err := graphqlidentity.ForContext(ctx, repo)
+	author, err := auth.UserFromCtx(ctx, repo)
 	if err != nil {
 		return nil, err
 	}
 
-	op, err := b.SetTitleRaw(id, time.Now().Unix(), input.Title, nil)
+	op, err := b.SetTitleRaw(author, time.Now().Unix(), input.Title, nil)
 	if err != nil {
 		return nil, err
 	}

graphql/resolvers/operations.go → api/graphql/resolvers/operations.go 🔗

@@ -5,9 +5,9 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.CreateOperationResolver = createOperationResolver{}

graphql/resolvers/query.go → api/graphql/resolvers/query.go 🔗

@@ -3,9 +3,9 @@ package resolvers
 import (
 	"context"
 
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.QueryResolver = &rootQueryResolver{}

graphql/resolvers/repo.go → api/graphql/resolvers/repo.go 🔗

@@ -3,12 +3,12 @@ package resolvers
 import (
 	"context"
 
+	"github.com/MichaelMure/git-bug/api/auth"
+	"github.com/MichaelMure/git-bug/api/graphql/connections"
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/graphql/connections"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/graphqlidentity"
-	"github.com/MichaelMure/git-bug/graphql/models"
 	"github.com/MichaelMure/git-bug/query"
 )
 
@@ -151,8 +151,8 @@ func (repoResolver) Identity(_ context.Context, obj *models.Repository, prefix s
 }
 
 func (repoResolver) UserIdentity(ctx context.Context, obj *models.Repository) (models.IdentityWrapper, error) {
-	id, err := graphqlidentity.ForContext(ctx, obj.Repo)
-	if err == graphqlidentity.ErrNotAuthenticated {
+	id, err := auth.UserFromCtx(ctx, obj.Repo)
+	if err == auth.ErrNotAuthenticated {
 		return nil, nil
 	} else if err != nil {
 		return nil, err

graphql/resolvers/root.go → api/graphql/resolvers/root.go 🔗

@@ -2,31 +2,31 @@
 package resolvers
 
 import (
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/graphql/graph"
 )
 
 var _ graph.ResolverRoot = &RootResolver{}
 
 type RootResolver struct {
-	cache.MultiRepoCache
+	*cache.MultiRepoCache
 }
 
-func NewRootResolver() *RootResolver {
+func NewRootResolver(mrc *cache.MultiRepoCache) *RootResolver {
 	return &RootResolver{
-		MultiRepoCache: cache.NewMultiRepoCache(),
+		MultiRepoCache: mrc,
 	}
 }
 
 func (r RootResolver) Query() graph.QueryResolver {
 	return &rootQueryResolver{
-		cache: &r.MultiRepoCache,
+		cache: r.MultiRepoCache,
 	}
 }
 
 func (r RootResolver) Mutation() graph.MutationResolver {
 	return &mutationResolver{
-		cache: &r.MultiRepoCache,
+		cache: r.MultiRepoCache,
 	}
 }
 

graphql/resolvers/timeline.go → api/graphql/resolvers/timeline.go 🔗

@@ -4,9 +4,9 @@ import (
 	"context"
 	"time"
 
+	"github.com/MichaelMure/git-bug/api/graphql/graph"
+	"github.com/MichaelMure/git-bug/api/graphql/models"
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/models"
 )
 
 var _ graph.CommentHistoryStepResolver = commentHistoryStepResolver{}

api/http/git_file_handler.go 🔗

@@ -0,0 +1,61 @@
+package http
+
+import (
+	"bytes"
+	"net/http"
+	"time"
+
+	"github.com/gorilla/mux"
+
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/util/git"
+)
+
+// implement a http.Handler that will read and server git blob.
+//
+// Expected gorilla/mux parameters:
+//   - "repo" : the ref of the repo or "" for the default one
+//   - "hash" : the git hash of the file to retrieve
+type gitFileHandler struct {
+	mrc *cache.MultiRepoCache
+}
+
+func NewGitFileHandler(mrc *cache.MultiRepoCache) http.Handler {
+	return &gitFileHandler{mrc: mrc}
+}
+
+func (gfh *gitFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	var repo *cache.RepoCache
+	var err error
+
+	repoVar := mux.Vars(r)["repo"]
+	switch repoVar {
+	case "":
+		repo, err = gfh.mrc.DefaultRepo()
+	default:
+		repo, err = gfh.mrc.ResolveRepo(repoVar)
+	}
+
+	if err != nil {
+		http.Error(rw, "invalid repo reference", http.StatusBadRequest)
+		return
+	}
+
+	hash := git.Hash(mux.Vars(r)["hash"])
+	if !hash.IsValid() {
+		http.Error(rw, "invalid git hash", http.StatusBadRequest)
+		return
+	}
+
+	// TODO: this mean that the whole file will he buffered in memory
+	// This can be a problem for big files. There might be a way around
+	// that by implementing a io.ReadSeeker that would read and discard
+	// data when a seek is called.
+	data, err := repo.ReadData(hash)
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	http.ServeContent(rw, r, "", time.Now(), bytes.NewReader(data))
+}

api/http/git_file_handlers_test.go 🔗

@@ -0,0 +1,91 @@
+package http
+
+import (
+	"bytes"
+	"image"
+	"image/png"
+	"mime/multipart"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gorilla/mux"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/MichaelMure/git-bug/api/auth"
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+func TestGitFileHandlers(t *testing.T) {
+	repo := repository.CreateTestRepo(false)
+	defer repository.CleanupTestRepos(repo)
+
+	mrc := cache.NewMultiRepoCache()
+	repoCache, err := mrc.RegisterDefaultRepository(repo)
+	require.NoError(t, err)
+
+	author, err := repoCache.NewIdentity("test identity", "test@test.org")
+	require.NoError(t, err)
+
+	err = repoCache.SetUserIdentity(author)
+	require.NoError(t, err)
+
+	// UPLOAD
+
+	uploadHandler := NewGitUploadFileHandler(mrc)
+
+	img := image.NewNRGBA(image.Rect(0, 0, 50, 50))
+	data := &bytes.Buffer{}
+	err = png.Encode(data, img)
+	require.NoError(t, err)
+
+	body := &bytes.Buffer{}
+	writer := multipart.NewWriter(body)
+	part, err := writer.CreateFormFile("uploadfile", "noname")
+	assert.NoError(t, err)
+
+	_, err = part.Write(data.Bytes())
+	assert.NoError(t, err)
+
+	err = writer.Close()
+	assert.NoError(t, err)
+
+	w := httptest.NewRecorder()
+	r, _ := http.NewRequest("GET", "/", body)
+	r.Header.Add("Content-Type", writer.FormDataContentType())
+
+	// Simulate auth
+	r = r.WithContext(auth.CtxWithUser(r.Context(), author.Id()))
+
+	// Handler's params
+	r = mux.SetURLVars(r, map[string]string{
+		"repo": "",
+	})
+
+	uploadHandler.ServeHTTP(w, r)
+
+	assert.Equal(t, http.StatusOK, w.Code)
+	assert.Equal(t, `{"hash":"3426a1488292d8f3f3c59ca679681336542b986f"}`, w.Body.String())
+	// DOWNLOAD
+
+	downloadHandler := NewGitFileHandler(mrc)
+
+	w = httptest.NewRecorder()
+	r, _ = http.NewRequest("GET", "/", nil)
+
+	// Simulate auth
+	r = r.WithContext(auth.CtxWithUser(r.Context(), author.Id()))
+
+	// Handler's params
+	r = mux.SetURLVars(r, map[string]string{
+		"repo": "",
+		"hash": "3426a1488292d8f3f3c59ca679681336542b986f",
+	})
+
+	downloadHandler.ServeHTTP(w, r)
+	assert.Equal(t, http.StatusOK, w.Code)
+
+	assert.Equal(t, data.Bytes(), w.Body.Bytes())
+}

api/http/git_file_upload_handler.go 🔗

@@ -0,0 +1,104 @@
+package http
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/gorilla/mux"
+
+	"github.com/MichaelMure/git-bug/api/auth"
+	"github.com/MichaelMure/git-bug/cache"
+)
+
+// implement a http.Handler that will accept and store content into git blob.
+//
+// Expected gorilla/mux parameters:
+//   - "repo" : the ref of the repo or "" for the default one
+type gitUploadFileHandler struct {
+	mrc *cache.MultiRepoCache
+}
+
+func NewGitUploadFileHandler(mrc *cache.MultiRepoCache) http.Handler {
+	return &gitUploadFileHandler{mrc: mrc}
+}
+
+func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	var repo *cache.RepoCache
+	var err error
+
+	repoVar := mux.Vars(r)["repo"]
+	switch repoVar {
+	case "":
+		repo, err = gufh.mrc.DefaultRepo()
+	default:
+		repo, err = gufh.mrc.ResolveRepo(repoVar)
+	}
+
+	if err != nil {
+		http.Error(rw, "invalid repo reference", http.StatusBadRequest)
+		return
+	}
+
+	_, err = auth.UserFromCtx(r.Context(), repo)
+	if err == auth.ErrNotAuthenticated {
+		http.Error(rw, "read-only mode or not logged in", http.StatusForbidden)
+		return
+	} else if err != nil {
+		http.Error(rw, fmt.Sprintf("loading identity: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	// 100MB (github limit)
+	var maxUploadSize int64 = 100 * 1000 * 1000
+	r.Body = http.MaxBytesReader(rw, r.Body, maxUploadSize)
+	if err := r.ParseMultipartForm(maxUploadSize); err != nil {
+		http.Error(rw, "file too big (100MB max)", http.StatusBadRequest)
+		return
+	}
+
+	file, _, err := r.FormFile("uploadfile")
+	if err != nil {
+		http.Error(rw, "invalid file", http.StatusBadRequest)
+		return
+	}
+	defer file.Close()
+	fileBytes, err := ioutil.ReadAll(file)
+	if err != nil {
+		http.Error(rw, "invalid file", http.StatusBadRequest)
+		return
+	}
+
+	filetype := http.DetectContentType(fileBytes)
+	if filetype != "image/jpeg" && filetype != "image/jpg" &&
+		filetype != "image/gif" && filetype != "image/png" {
+		http.Error(rw, "invalid file type", http.StatusBadRequest)
+		return
+	}
+
+	hash, err := repo.StoreData(fileBytes)
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	type response struct {
+		Hash string `json:"hash"`
+	}
+
+	resp := response{Hash: string(hash)}
+
+	js, err := json.Marshal(resp)
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	rw.Header().Set("Content-Type", "application/json")
+	_, err = rw.Write(js)
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}

cache/multi_repo_cache.go 🔗

@@ -13,32 +13,32 @@ type MultiRepoCache struct {
 	repos map[string]*RepoCache
 }
 
-func NewMultiRepoCache() MultiRepoCache {
-	return MultiRepoCache{
+func NewMultiRepoCache() *MultiRepoCache {
+	return &MultiRepoCache{
 		repos: make(map[string]*RepoCache),
 	}
 }
 
 // RegisterRepository register a named repository. Use this for multi-repo setup
-func (c *MultiRepoCache) RegisterRepository(ref string, repo repository.ClockedRepo) error {
+func (c *MultiRepoCache) RegisterRepository(ref string, repo repository.ClockedRepo) (*RepoCache, error) {
 	r, err := NewRepoCache(repo)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	c.repos[ref] = r
-	return nil
+	return r, nil
 }
 
 // RegisterDefaultRepository register a unnamed repository. Use this for mono-repo setup
-func (c *MultiRepoCache) RegisterDefaultRepository(repo repository.ClockedRepo) error {
+func (c *MultiRepoCache) RegisterDefaultRepository(repo repository.ClockedRepo) (*RepoCache, error) {
 	r, err := NewRepoCache(repo)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	c.repos[""] = r
-	return nil
+	return r, nil
 }
 
 // DefaultRepo retrieve the default repository

cache/repo_cache.go 🔗

@@ -142,6 +142,16 @@ func (c *RepoCache) GetUserEmail() (string, error) {
 	return c.repo.GetUserEmail()
 }
 
+// ReadData will attempt to read arbitrary data from the given hash
+func (c *RepoCache) ReadData(hash git.Hash) ([]byte, error) {
+	return c.repo.ReadData(hash)
+}
+
+// StoreData will store arbitrary data and return the corresponding hash
+func (c *RepoCache) StoreData(data []byte) (git.Hash, error) {
+	return c.repo.StoreData(data)
+}
+
 func (c *RepoCache) lock() error {
 	lockPath := repoLockFilePath(c.repo)
 

commands/webui.go 🔗

@@ -1,11 +1,8 @@
 package commands
 
 import (
-	"bytes"
 	"context"
-	"encoding/json"
 	"fmt"
-	"io/ioutil"
 	"log"
 	"net/http"
 	"os"
@@ -18,11 +15,12 @@ import (
 	"github.com/skratchdot/open-golang/open"
 	"github.com/spf13/cobra"
 
-	"github.com/MichaelMure/git-bug/graphql"
-	"github.com/MichaelMure/git-bug/graphql/graphqlidentity"
+	"github.com/MichaelMure/git-bug/api/auth"
+	"github.com/MichaelMure/git-bug/api/graphql"
+	httpapi "github.com/MichaelMure/git-bug/api/http"
+	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
-	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/webui"
 )
 
@@ -35,15 +33,6 @@ var (
 
 const webUIOpenConfigKey = "git-bug.webui.open"
 
-func authMiddleware(id *identity.Identity) func(http.Handler) http.Handler {
-	return func(next http.Handler) http.Handler {
-		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			ctx := graphqlidentity.AttachToContext(r.Context(), id)
-			next.ServeHTTP(w, r.WithContext(ctx))
-		})
-	}
-}
-
 func runWebUI(cmd *cobra.Command, args []string) error {
 	if webUIPort == 0 {
 		var err error
@@ -53,38 +42,36 @@ func runWebUI(cmd *cobra.Command, args []string) error {
 		}
 	}
 
-	var id *identity.Identity
+	addr := fmt.Sprintf("127.0.0.1:%d", webUIPort)
+	webUiAddr := fmt.Sprintf("http://%s", addr)
+
+	router := mux.NewRouter()
+
+	// If the webUI is not read-only, use an authentication middleware with a
+	// fixed identity: the default user of the repo
+	// TODO: support dynamic authentication with OAuth
 	if !webUIReadOnly {
-		// Verify that we have an identity.
-		var err error
-		id, err = identity.GetUserIdentity(repo)
+		author, err := identity.GetUserIdentity(repo)
 		if err != nil {
 			return err
 		}
+		router.Use(auth.Middleware(author.Id()))
 	}
 
-	addr := fmt.Sprintf("127.0.0.1:%d", webUIPort)
-	webUiAddr := fmt.Sprintf("http://%s", addr)
-
-	router := mux.NewRouter()
-
-	graphqlHandler, err := graphql.NewHandler(repo)
+	mrc := cache.NewMultiRepoCache()
+	_, err := mrc.RegisterDefaultRepository(repo)
 	if err != nil {
 		return err
 	}
 
-	assetsHandler := &fileSystemWithDefault{
-		FileSystem:  webui.WebUIAssets,
-		defaultFile: "index.html",
-	}
+	graphqlHandler := graphql.NewHandler(mrc)
 
 	// Routes
 	router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
 	router.Path("/graphql").Handler(graphqlHandler)
-	router.Path("/gitfile/{hash}").Handler(newGitFileHandler(repo))
-	router.Path("/upload").Methods("POST").Handler(newGitUploadFileHandler(repo))
-	router.PathPrefix("/").Handler(http.FileServer(assetsHandler))
-	router.Use(authMiddleware(id))
+	router.Path("/gitfile/{repo}/{hash}").Handler(httpapi.NewGitFileHandler(mrc))
+	router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
+	router.PathPrefix("/").Handler(webui.NewHandler())
 
 	srv := &http.Server{
 		Addr:    addr,
@@ -151,128 +138,6 @@ func runWebUI(cmd *cobra.Command, args []string) error {
 	return nil
 }
 
-// implement a http.FileSystem that will serve a default file when the looked up
-// file doesn't exist. Useful for Single-Page App that implement routing client
-// side, where the server has to return the root index.html file for every route.
-type fileSystemWithDefault struct {
-	http.FileSystem
-	defaultFile string
-}
-
-func (fswd *fileSystemWithDefault) Open(name string) (http.File, error) {
-	f, err := fswd.FileSystem.Open(name)
-	if os.IsNotExist(err) {
-		return fswd.FileSystem.Open(fswd.defaultFile)
-	}
-	return f, err
-}
-
-// implement a http.Handler that will read and server git blob.
-type gitFileHandler struct {
-	repo repository.Repo
-}
-
-func newGitFileHandler(repo repository.Repo) http.Handler {
-	return &gitFileHandler{
-		repo: repo,
-	}
-}
-
-func (gfh *gitFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
-	hash := git.Hash(mux.Vars(r)["hash"])
-
-	if !hash.IsValid() {
-		http.Error(rw, "invalid git hash", http.StatusBadRequest)
-		return
-	}
-
-	// TODO: this mean that the whole file will he buffered in memory
-	// This can be a problem for big files. There might be a way around
-	// that by implementing a io.ReadSeeker that would read and discard
-	// data when a seek is called.
-	data, err := gfh.repo.ReadData(git.Hash(hash))
-	if err != nil {
-		http.Error(rw, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	http.ServeContent(rw, r, "", time.Now(), bytes.NewReader(data))
-}
-
-// implement a http.Handler that will accept and store content into git blob.
-type gitUploadFileHandler struct {
-	repo repository.Repo
-}
-
-func newGitUploadFileHandler(repo repository.Repo) http.Handler {
-	return &gitUploadFileHandler{
-		repo: repo,
-	}
-}
-
-func (gufh *gitUploadFileHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
-	_, err := graphqlidentity.ForContextUncached(r.Context(), gufh.repo)
-	if err == graphqlidentity.ErrNotAuthenticated {
-		http.Error(rw, fmt.Sprintf("read-only mode or not logged in"), http.StatusForbidden)
-		return
-	} else if err != nil {
-		http.Error(rw, fmt.Sprintf("loading identity: %v", err), http.StatusInternalServerError)
-		return
-	}
-
-	// 100MB (github limit)
-	var maxUploadSize int64 = 100 * 1000 * 1000
-	r.Body = http.MaxBytesReader(rw, r.Body, maxUploadSize)
-	if err := r.ParseMultipartForm(maxUploadSize); err != nil {
-		http.Error(rw, "file too big (100MB max)", http.StatusBadRequest)
-		return
-	}
-
-	file, _, err := r.FormFile("uploadfile")
-	if err != nil {
-		http.Error(rw, "invalid file", http.StatusBadRequest)
-		return
-	}
-	defer file.Close()
-	fileBytes, err := ioutil.ReadAll(file)
-	if err != nil {
-		http.Error(rw, "invalid file", http.StatusBadRequest)
-		return
-	}
-
-	filetype := http.DetectContentType(fileBytes)
-	if filetype != "image/jpeg" && filetype != "image/jpg" &&
-		filetype != "image/gif" && filetype != "image/png" {
-		http.Error(rw, "invalid file type", http.StatusBadRequest)
-		return
-	}
-
-	hash, err := gufh.repo.StoreData(fileBytes)
-	if err != nil {
-		http.Error(rw, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	type response struct {
-		Hash string `json:"hash"`
-	}
-
-	resp := response{Hash: string(hash)}
-
-	js, err := json.Marshal(resp)
-	if err != nil {
-		http.Error(rw, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
-	rw.Header().Set("Content-Type", "application/json")
-	_, err = rw.Write(js)
-	if err != nil {
-		http.Error(rw, err.Error(), http.StatusInternalServerError)
-		return
-	}
-}
-
 var webUICmd = &cobra.Command{
 	Use:   "webui",
 	Short: "Launch the web UI.",
@@ -294,5 +159,4 @@ func init() {
 	webUICmd.Flags().BoolVar(&webUINoOpen, "no-open", false, "Prevent the automatic opening of the web UI in the default browser")
 	webUICmd.Flags().IntVarP(&webUIPort, "port", "p", 0, "Port to listen to (default is random)")
 	webUICmd.Flags().BoolVar(&webUIReadOnly, "read-only", false, "Whether to run the web UI in read-only mode")
-
 }

graphql/graphqlidentity/graphqlidentity.go 🔗

@@ -1,41 +0,0 @@
-// Package graphqlidentity contains helpers for managing identities within the GraphQL API.
-package graphqlidentity
-
-import (
-	"context"
-
-	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/identity"
-	"github.com/MichaelMure/git-bug/repository"
-)
-
-// identityCtxKey is a unique context key, accessible only in this package.
-var identityCtxKey = &struct{}{}
-
-// AttachToContext attaches an Identity to a context.
-func AttachToContext(ctx context.Context, u *identity.Identity) context.Context {
-	return context.WithValue(ctx, identityCtxKey, u.Id())
-}
-
-// ForContext retrieves an IdentityCache from the context.
-// If there is no identity in the context, ErrNotAuthenticated is returned.
-// If an error occurs while resolving the identity (e.g. I/O error), then it will be returned.
-func ForContext(ctx context.Context, r *cache.RepoCache) (*cache.IdentityCache, error) {
-	id, ok := ctx.Value(identityCtxKey).(entity.Id)
-	if !ok {
-		return nil, ErrNotAuthenticated
-	}
-	return r.ResolveIdentity(id)
-}
-
-// ForContextUncached retrieves an Identity from the context.
-// If there is no identity in the context, ErrNotAuthenticated is returned.
-// If an error occurs while resolving the identity (e.g. I/O error), then it will be returned.
-func ForContextUncached(ctx context.Context, repo repository.Repo) (*identity.Identity, error) {
-	id, ok := ctx.Value(identityCtxKey).(entity.Id)
-	if !ok {
-		return nil, ErrNotAuthenticated
-	}
-	return identity.ReadLocal(repo, id)
-}

graphql/handler.go 🔗

@@ -1,39 +0,0 @@
-//go:generate go run gen_graphql.go
-
-// Package graphql contains the root GraphQL http handler
-package graphql
-
-import (
-	"net/http"
-
-	"github.com/99designs/gqlgen/graphql/handler"
-
-	"github.com/MichaelMure/git-bug/graphql/graph"
-	"github.com/MichaelMure/git-bug/graphql/resolvers"
-	"github.com/MichaelMure/git-bug/repository"
-)
-
-// Handler is the root GraphQL http handler
-type Handler struct {
-	http.Handler
-	*resolvers.RootResolver
-}
-
-func NewHandler(repo repository.ClockedRepo) (Handler, error) {
-	h := Handler{
-		RootResolver: resolvers.NewRootResolver(),
-	}
-
-	err := h.RootResolver.RegisterDefaultRepository(repo)
-	if err != nil {
-		return Handler{}, err
-	}
-
-	config := graph.Config{
-		Resolvers: h.RootResolver,
-	}
-
-	h.Handler = handler.NewDefaultServer(graph.NewExecutableSchema(config))
-
-	return h, nil
-}

webui/codegen.yaml 🔗

@@ -1,4 +1,4 @@
-schema: '../graphql/schema/*.graphql'
+schema: '../api/graphql/schema/*.graphql'
 overwrite: true
 documents: src/**/*.graphql
 generates:

webui/handler.go 🔗

@@ -0,0 +1,31 @@
+package webui
+
+import (
+	"net/http"
+	"os"
+)
+
+// implement a http.FileSystem that will serve a default file when the looked up
+// file doesn't exist. Useful for Single-Page App that implement routing client
+// side, where the server has to return the root index.html file for every route.
+type fileSystemWithDefault struct {
+	http.FileSystem
+	defaultFile string
+}
+
+func (fswd *fileSystemWithDefault) Open(name string) (http.File, error) {
+	f, err := fswd.FileSystem.Open(name)
+	if os.IsNotExist(err) {
+		return fswd.FileSystem.Open(fswd.defaultFile)
+	}
+	return f, err
+}
+
+func NewHandler() http.Handler {
+	assetsHandler := &fileSystemWithDefault{
+		FileSystem:  WebUIAssets,
+		defaultFile: "index.html",
+	}
+
+	return http.FileServer(assetsHandler)
+}