backend-refactor.md 4.7 KB

Backend Refactoring

The backend refactor has four immediate goals:

  • Remove repeated logic from handlers, to make handlers easier to develop and test
  • Create unit tests for handlers
  • Consolidate error handling and logging
  • Type the API request/response/endpoints and export the types as a package

These tasks will eventually enable:

  • Application monitoring and error reporting, using the conslidated error handlers and logging
  • Better support with well-defined and predictable logging output
  • Proper CI when developing, which can run unit tests against the backend
  • Auto-generation of the API spec, using the strongly types API requests/responses

Explanation of Scopes

This refactor abstracts authentication and authorization logic much better than the previous version of the API. The refactor is centered around the concept of scopes: each endpoint has a well-defined set of scopes that define which objects that the endpoint can modify or return, thereby enforcing Role-Based Access Control. The basic set of scopes operate in the following heirarchy:

             User
              |
           Project
           /      \
        Cluster   Settings
         /
    Namespace
       |
     Release

That is, all project routes are user-scoped (require authenticated user), all cluster scopes require a project, etc.

How do we enforce these scopes? This is the purpose of the api/server/authz package, where we make use of Golang's context to create middleware that adds resources to the context. For example, the ProjectScopedMiddleware object returns a handler that essentially does the following (removed fluff from code):

// get the referenced project from the database
project, _ := p.config.Repo.Project().ReadProject(projID)

// add the project to the request context
ctx := NewProjectContext(r.Context(), project)
r = r.Clone(ctx)

// call the next handler
p.next.ServeHTTP(w, r)

Thus, subsequent endpoint can simply call the following:

proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)

Note that we assume the resource is populated in subsequent handlers -- we do not check for this condition, since this is the function of the middleware.

Migrating Existing Handlers

The steps for migrating an existing handler are as follows:

  1. Create a new file in a sub-folder of /api/server/handlers. For example, if creating a cluster-scoped routes, add a new route to /api/server/handlers/cluster.
  2. Add some boilerplate to this file as follows:

    type [Resource][Verb]Handler struct {
    // should extend one of:
    // handlers.PorterHandler
    // handlers.PorterHandlerReader
    // handlers.PorterHandlerWriter
    // handlers.PorterHandlerReaderWriter
    
    // TODO: add any additional fields, or embed existing structs/interfaces
    }
    
    func New[Resource][Verb]Handler(
    	config *config.Config,
    // TODO: additional arguments
    ) *[Resource][Verb]Handler {
    	return &[Resource][Verb]Handler{
        // TODO: initialize
    	}
    }
    
    func (c *[Resource][Verb]Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // TODO: add handler logic here
    }
    
  3. Implement the handler logic, you can mostly copy the handler from /server/api. Perform the following:

  • Replace all unnecessary lookups by finding the object in context (see above for explanation)
  • Replace all error-handling with a method from apierrors (look at existing handlers to understand)
  • Use helper methods attached to the handlers.PorterHandler interface, which implements the following:

    Config() *config.Config
    Repo() repository.Repository
    HandleAPIError(w http.ResponseWriter, err apierrors.RequestError)
    
  1. Add the route to the corresponding file in /api/server/router. Each route has a corresponding endpoint and handler. For example, if creating a new cluster-scoped route, like GET /api/projects/{project_id}/clusters/{cluster_id}, you would add the following to getClusterRoutes:

    // GET /api/projects/{project_id}/clusters/{cluster_id} -> project.NewClusterGetHandler
    getEndpoint := factory.NewAPIEndpoint(
    	&types.APIRequestMetadata{
    		Verb:   types.APIVerbGet,
    		Method: types.HTTPVerbGet,
    		Path: &types.Path{
    			Parent:       basePath,
    			RelativePath: relPath,
    		},
    		Scopes: []types.PermissionScope{
            // NOTE: important. Make sure all relevant scopes are defined: this definition will
            // automatically add the relevant middleware.
    			types.UserScope,
    			types.ProjectScope,
    			types.ClusterScope,
    		},
    	},
    )
    
    getHandler := cluster.NewClusterGetHandler(
    	config,
    	factory.GetResultWriter(),
    )
    
    routes = append(routes, &Route{
    	Endpoint: getEndpoint,
    	Handler:  getHandler,
    	Router:   r,
    })