Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bc2819d
docs: theme modification with nixopus-brand (#427)
zhravan Sep 19, 2025
798fef9
feat: extensions ui design with dummy data
raghavyuva Sep 19, 2025
5d302ac
feat: add migrations for extensions permissions, auditing, feature flags
raghavyuva Sep 20, 2025
340ff69
fix: wrap extension page and sidebar in feature flag and rbac guards
raghavyuva Sep 20, 2025
a0c64aa
feat: define migration for extensions
raghavyuva Sep 20, 2025
12661f0
feat: extension discovery and saving to database on api init
raghavyuva Sep 20, 2025
9a5e87a
feat: add routes for listing extensions
raghavyuva Sep 20, 2025
52d3331
feat: rename extension permission migration files
raghavyuva Sep 20, 2025
882c741
feat: allow pagination search, sorting and integrate with view
raghavyuva Sep 20, 2025
a61334f
Merge branch 'feat/develop' into feat/extensions
raghavyuva Sep 20, 2025
6867251
Merge branch 'master' into feat/develop
raghavyuva Sep 20, 2025
1c1485b
Merge branch 'feat/develop' into feat/extensions
raghavyuva Sep 20, 2025
9572671
feat: enable extension execution with run and cancel apis (#455)
raghavyuva Oct 6, 2025
76238a7
feat: extension forking (#464)
raghavyuva Oct 7, 2025
7fcee25
feat: extension details (#470)
raghavyuva Oct 7, 2025
808cad0
enhancement(ui): show pagination when page > 1 (#494)
Amanthisside Oct 13, 2025
7b4020b
Merge branch 'feat/develop' into feat/extensions
raghavyuva Oct 13, 2025
3b03319
fix: extension step execution
raghavyuva Oct 13, 2025
4c3db53
fix: rbac permissions according to supertokens changes
raghavyuva Oct 13, 2025
9c89d61
feat: refactor extension executor
raghavyuva Oct 14, 2025
fac665f
feat: log extension execution
raghavyuva Oct 14, 2025
9558b28
fix: search and sorting in extensions
raghavyuva Oct 14, 2025
1bd2fa0
feat: add deploy templates
raghavyuva Oct 14, 2025
dab77db
fix: overflow of descriptions with readmore option
raghavyuva Oct 15, 2025
0e5d58d
feat: extension category as badges
raghavyuva Oct 15, 2025
e88d011
feat: add button for install / run in extension detail page
raghavyuva Oct 15, 2025
9e490dc
feat: display of status colors based on extension running
raghavyuva Oct 15, 2025
54c82ae
chore: update ml.json with extension related translations
raghavyuva Oct 15, 2025
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
4 changes: 2 additions & 2 deletions api/api/versions.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
{
"version": "v1",
"status": "active",
"release_date": "2025-10-11T20:19:39.955308752+05:30",
"release_date": "2025-10-15T21:35:07.482629937+05:30",
"end_of_life": "0001-01-01T00:00:00Z",
"changes": [
"Initial API version"
]
}
]
}
}
2 changes: 1 addition & 1 deletion api/doc/openapi.json

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions api/internal/features/extension/controller/delete_extension.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package controller

import (
"net/http"

"github.com/go-fuego/fuego"
"github.com/raghavyuva/nixopus-api/internal/features/logger"
)
Comment on lines +3 to +8
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Return meaningful errors and proper status codes

  • Missing id returns 400 with nil error → no message.
  • Service errors should not default to 400; map not found to 404, others to 500.

Apply this diff:

 package controller

 import (
 	"net/http"
+	"database/sql"
+	"errors"

 	"github.com/go-fuego/fuego"
 	"github.com/raghavyuva/nixopus-api/internal/features/logger"
 )
 
 func (c *ExtensionsController) DeleteFork(ctx fuego.ContextNoBody) (*struct {
 	Status string `json:"status"`
 }, error) {
 	id := ctx.PathParam("id")
 	if id == "" {
-		return nil, fuego.HTTPError{Err: nil, Status: http.StatusBadRequest}
+		return nil, fuego.HTTPError{Err: errors.New("missing id"), Status: http.StatusBadRequest}
 	}
 	if err := c.service.DeleteFork(id); err != nil {
 		c.logger.Log(logger.Error, err.Error(), "")
-		return nil, fuego.HTTPError{Err: err, Status: http.StatusBadRequest}
+		status := http.StatusInternalServerError
+		if errors.Is(err, sql.ErrNoRows) {
+			status = http.StatusNotFound
+		}
+		return nil, fuego.HTTPError{Err: err, Status: status}
 	}
 	return &struct {
 		Status string `json:"status"`
 	}{Status: "ok"}, nil
 }

Also applies to: 15-20

🤖 Prompt for AI Agents
In api/internal/features/extension/controller/delete_extension.go around lines
3-8 (also apply same changes at lines 15-20), the handler currently returns a
400 with a nil/empty body when id is missing and maps all service errors to 400;
update it so that when the id param is missing or empty it returns
http.StatusBadRequest with a JSON/plain error message explaining "missing id"
(or similar), and change service error handling to inspect the error: if the
service returns a not-found error (e.g., compare to a sentinel ErrNotFound or
check error type/message) return http.StatusNotFound with an explanatory
message, otherwise return http.StatusInternalServerError with the error message;
ensure processLogger logs the error and that responses include meaningful error
messages and proper status codes.


func (c *ExtensionsController) DeleteFork(ctx fuego.ContextNoBody) (*struct {
Status string `json:"status"`
}, error) {
id := ctx.PathParam("id")
if id == "" {
return nil, fuego.HTTPError{Err: nil, Status: http.StatusBadRequest}
}
if err := c.service.DeleteFork(id); err != nil {
c.logger.Log(logger.Error, err.Error(), "")
return nil, fuego.HTTPError{Err: err, Status: http.StatusBadRequest}
}
return &struct {
Status string `json:"status"`
}{Status: "ok"}, nil
}
40 changes: 40 additions & 0 deletions api/internal/features/extension/controller/fork_extension.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package controller

import (
"net/http"

"github.com/go-fuego/fuego"
"github.com/raghavyuva/nixopus-api/internal/features/logger"
"github.com/raghavyuva/nixopus-api/internal/types"
)

type ForkExtensionRequest struct {
YAMLContent *string `json:"yaml_content"`
}

func (c *ExtensionsController) ForkExtension(ctx fuego.ContextWithBody[ForkExtensionRequest]) (types.Extension, error) {
extensionID := ctx.PathParam("extension_id")
if extensionID == "" {
return types.Extension{}, fuego.HTTPError{Err: nil, Status: http.StatusBadRequest}
}
req, err := ctx.Body()
if err != nil {
return types.Extension{}, fuego.HTTPError{Err: err, Status: http.StatusBadRequest}
}
var yamlOverride string
if req.YAMLContent != nil {
yamlOverride = *req.YAMLContent
}
authorName := ""
if userAny := ctx.Request().Context().Value(types.UserContextKey); userAny != nil {
if u, ok := userAny.(*types.User); ok && u != nil {
authorName = u.Username
}
}
newExt, err := c.service.ForkExtension(extensionID, yamlOverride, authorName)
if err != nil {
c.logger.Log(logger.Error, err.Error(), "")
return types.Extension{}, fuego.HTTPError{Err: err, Status: http.StatusInternalServerError}
}
return *newExt, nil
}
167 changes: 167 additions & 0 deletions api/internal/features/extension/controller/get_extensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package controller

import (
"net/http"
"strconv"

"github.com/go-fuego/fuego"
"github.com/raghavyuva/nixopus-api/internal/features/logger"
"github.com/raghavyuva/nixopus-api/internal/types"
)

func (c *ExtensionsController) GetExtensions(ctx fuego.ContextNoBody) (*types.ExtensionListResponse, error) {
params := types.ExtensionListParams{}

categoryParam := ctx.QueryParam("category")
if categoryParam != "" {
cat := types.ExtensionCategory(categoryParam)
params.Category = &cat
}

searchParam := ctx.QueryParam("search")
if searchParam != "" {
params.Search = searchParam
}

if typeParam := ctx.QueryParam("type"); typeParam != "" {
et := types.ExtensionType(typeParam)
params.Type = &et
}

sortByParam := ctx.QueryParam("sort_by")
if sortByParam != "" {
params.SortBy = types.ExtensionSortField(sortByParam)
}

sortDirParam := ctx.QueryParam("sort_dir")
if sortDirParam != "" {
params.SortDir = types.SortDirection(sortDirParam)
}

pageParam := ctx.QueryParam("page")
if pageParam != "" {
if page, err := strconv.Atoi(pageParam); err == nil && page > 0 {
params.Page = page
}
}

pageSizeParam := ctx.QueryParam("page_size")
if pageSizeParam != "" {
if pageSize, err := strconv.Atoi(pageSizeParam); err == nil && pageSize > 0 {
params.PageSize = pageSize
}
}

response, err := c.service.ListExtensions(params)
if err != nil {
c.logger.Log(logger.Error, err.Error(), "")
return nil, fuego.HTTPError{
Err: err,
Status: http.StatusInternalServerError,
}
}

return response, nil
}

func (c *ExtensionsController) GetCategories(ctx fuego.ContextNoBody) ([]types.ExtensionCategory, error) {
cats, err := c.service.ListCategories()
if err != nil {
c.logger.Log(logger.Error, err.Error(), "")
return nil, fuego.HTTPError{Err: err, Status: http.StatusInternalServerError}
}
return cats, nil
}

func (c *ExtensionsController) GetExtension(ctx fuego.ContextNoBody) (types.Extension, error) {
id := ctx.PathParam("id")
if id == "" {
return types.Extension{}, fuego.HTTPError{
Err: nil,
Status: http.StatusBadRequest,
}
}
Comment on lines +77 to +83
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Bad request responses should include an error message.

Nil Err degrades API ergonomics.

-	if id == "" {
-		return types.Extension{}, fuego.HTTPError{
-			Err:    nil,
-			Status: http.StatusBadRequest,
-		}
-	}
+	if id == "" {
+		return types.Extension{}, fuego.HTTPError{
+			Err:    fmt.Errorf("missing path parameter: id"),
+			Status: http.StatusBadRequest,
+		}
+	}

Apply similarly in Lines 95-101, 123-128, and 143-148 for extension_id/execution_id.

🤖 Prompt for AI Agents
In api/internal/features/extension/controller/get_extensions.go around lines
68-74, 95-101, 123-128 and 143-148 the HTTPError returned for missing path
parameters uses Err=nil which yields poor API ergonomics; update each check to
return a non-nil error with a descriptive message (e.g., errors.New("missing
id") or errors.New("missing extension_id"/"missing execution_id" as appropriate)
and keep the same Status (http.StatusBadRequest) so callers receive both the
HTTP status and a clear error message.


extension, err := c.service.GetExtension(id)
if err != nil {
if err.Error() == "extension not found" {
return types.Extension{}, fuego.HTTPError{
Err: err,
Status: http.StatusNotFound,
}
}
Comment on lines +86 to +92
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Avoid brittle error detection with string comparison.

Line 87 uses string comparison to detect "extension not found" errors. This is fragile and will break if the error message changes.

Consider using sentinel errors or custom error types for better error handling.

Example approach:

// In types/extension.go or errors.go
var ErrExtensionNotFound = errors.New("extension not found")

// In service layer, return:
return nil, ErrExtensionNotFound

// In controller, check:
if errors.Is(err, types.ErrExtensionNotFound) {
  return types.Extension{}, fuego.HTTPError{
    Err:    err,
    Status: http.StatusNotFound,
  }
}
🤖 Prompt for AI Agents
In api/internal/features/extension/controller/get_extensions.go around lines 86
to 92, the controller currently detects a missing extension by comparing
err.Error() to a literal string which is brittle; instead define a sentinel
error (e.g., ErrExtensionNotFound) in the extension types or a shared errors
file and have the service layer return that sentinel when an extension isn’t
found, then modify this controller to import the standard errors package and use
errors.Is(err, types.ErrExtensionNotFound) to detect the not-found case and
return the fuego.HTTPError with StatusNotFound, removing the string comparison
and ensuring the service returns the sentinel error.

c.logger.Log(logger.Error, err.Error(), "")
return types.Extension{}, fuego.HTTPError{
Err: err,
Status: http.StatusInternalServerError,
}
}

return *extension, nil
}

func (c *ExtensionsController) GetExtensionByExtensionID(ctx fuego.ContextNoBody) (types.Extension, error) {
extensionID := ctx.PathParam("extension_id")
if extensionID == "" {
return types.Extension{}, fuego.HTTPError{
Err: nil,
Status: http.StatusBadRequest,
}
}

extension, err := c.service.GetExtensionByID(extensionID)
if err != nil {
if err.Error() == "extension not found" {
return types.Extension{}, fuego.HTTPError{
Err: err,
Status: http.StatusNotFound,
}
}
c.logger.Log(logger.Error, err.Error(), "")
return types.Extension{}, fuego.HTTPError{
Err: err,
Status: http.StatusInternalServerError,
}
}

return *extension, nil
}

func (c *ExtensionsController) GetExecution(ctx fuego.ContextNoBody) (*types.ExtensionExecution, error) {
id := ctx.PathParam("execution_id")
if id == "" {
return nil, fuego.HTTPError{
Err: nil,
Status: http.StatusBadRequest,
}
}

exec, err := c.service.GetExecutionByID(id)
if err != nil {
c.logger.Log(logger.Error, err.Error(), "")
return nil, fuego.HTTPError{
Err: err,
Status: http.StatusInternalServerError,
}
}
return exec, nil
}

func (c *ExtensionsController) ListExecutionsByExtensionID(ctx fuego.ContextNoBody) ([]types.ExtensionExecution, error) {
extensionID := ctx.PathParam("extension_id")
if extensionID == "" {
return nil, fuego.HTTPError{
Err: nil,
Status: http.StatusBadRequest,
}
}
execs, err := c.service.ListExecutionsByExtensionID(extensionID)
if err != nil {
c.logger.Log(logger.Error, err.Error(), "")
return nil, fuego.HTTPError{
Err: err,
Status: http.StatusInternalServerError,
}
}
return execs, nil
}
72 changes: 72 additions & 0 deletions api/internal/features/extension/controller/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package controller

import (
"context"
"net/http"

"github.com/raghavyuva/nixopus-api/internal/features/extension/service"
"github.com/raghavyuva/nixopus-api/internal/features/extension/storage"
"github.com/raghavyuva/nixopus-api/internal/features/extension/validation"
"github.com/raghavyuva/nixopus-api/internal/features/logger"
shared_storage "github.com/raghavyuva/nixopus-api/internal/storage"
"github.com/raghavyuva/nixopus-api/internal/utils"
)

type ExtensionsController struct {
store *shared_storage.Store
service *service.ExtensionService
validator *validation.Validator
ctx context.Context
logger logger.Logger
}

func NewExtensionsController(
store *shared_storage.Store,
ctx context.Context,
l logger.Logger,
) *ExtensionsController {
storage := storage.ExtensionStorage{DB: store.DB, Ctx: ctx}
return &ExtensionsController{
store: store,
service: service.NewExtensionService(store, ctx, l, &storage),
validator: validation.NewValidator(&storage),
ctx: ctx,
logger: l,
}
}

// parseAndValidate parses and validates the request body.
//
// This method attempts to parse the request body into the provided 'req' interface
// using the controller's validator. If parsing fails, an error response is sent
// and the method returns false. It also validates the parsed request object and
// returns false if validation fails. If both operations are successful, it returns true.
//
// Parameters:
//
// w - the HTTP response writer to send error responses.
// r - the HTTP request containing the body to parse.
// req - the interface to populate with the parsed request body.
//
// Returns:
//
// bool - true if parsing and validation succeed, false otherwise.
func (c *ExtensionsController) parseAndValidate(w http.ResponseWriter, r *http.Request, req interface{}) bool {
if err := c.validator.ParseRequestBody(r, r.Body, req); err != nil {
c.logger.Log(logger.Error, "Failed to decode request", err.Error())
utils.SendErrorResponse(w, "Failed to decode request", http.StatusBadRequest)
return false
}

if err := c.validator.ValidateRequest(req); err != nil {
c.logger.Log(logger.Error, err.Error(), err.Error())
utils.SendErrorResponse(w, err.Error(), http.StatusBadRequest)
return false
}

return true
}

type RunExtensionRequest struct {
Variables map[string]interface{} `json:"variables"`
}
Loading
Loading