diff --git a/_bruno/Flows/Create Flow.yml b/_bruno/Flows/Create Flow.yml new file mode 100644 index 0000000..ccaa98d --- /dev/null +++ b/_bruno/Flows/Create Flow.yml @@ -0,0 +1,86 @@ +info: + name: Create Flow + type: http + seq: 1 + +http: + method: POST + url: "{{baseUrl}}/api/products/:id/flows" + params: + - name: id + value: "2" + type: path + body: + type: json + data: |- + { + "name": "Flow 1", + "description": "flow 1 desc" + } + auth: inherit + +runtime: + assertions: + - expression: res.status + operator: eq + value: "201" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 + +examples: + - name: sample + description: sample successful call to create a product flow + request: + url: "{{baseUrl}}/api/products/:id/flows" + method: POST + params: + - name: id + value: "2" + type: path + body: + type: json + data: |- + { + "name": "Flow 1", + "description": "flow 1 desc" + } + response: + status: 201 + statusText: Created + headers: + - name: content-type + value: application/json + - name: vary + value: Origin, Accept-Encoding + - name: date + value: Sat, 16 May 2026 00:11:09 GMT + - name: content-length + value: "134" + body: + type: json + data: |- + { + "id": 1, + "product_id": 2, + "name": "Flow 1", + "description": "flow 1 desc", + "created_at": "2026-05-15T19:11:09.35732-05:00", + "updated_at": "2026-05-15T19:11:09.35732-05:00" + } + +docs: |- + # Create Flow + + Creates a new flow for a specific product. The product ID is passed as a path parameter. + + ## Possible Status Codes + | Status Code | Notes | + | -- | --| + | 201 | Created | + | 400 | Invalid product ID or request body | + | 404 | Product not found | + | 500 | Internal server error | diff --git a/_bruno/Flows/folder.yml b/_bruno/Flows/folder.yml new file mode 100644 index 0000000..1b2e7d9 --- /dev/null +++ b/_bruno/Flows/folder.yml @@ -0,0 +1,7 @@ +info: + name: Flows + type: folder + seq: 3 + +request: + auth: inherit diff --git a/internal/flow/flows.sql b/internal/flow/flows.sql index 0297296..5ff56d5 100644 --- a/internal/flow/flows.sql +++ b/internal/flow/flows.sql @@ -1,6 +1,7 @@ --- name: CreateFlow :execrows +-- name: CreateFlow :one INSERT INTO flows(product_id, name, description, created_at, updated_at) -VALUES(@product_id, @name, @description, @time_stamp, @time_stamp); +VALUES(@product_id, @name, @description, @time_stamp, @time_stamp) +RETURNING *; -- name: GetFlowsByProduct :many SELECT id, name, description, created_at, updated_at FROM flows WHERE product_id = @product_id; diff --git a/internal/flow/flows.sql.gen.go b/internal/flow/flows.sql.gen.go index f773af4..9f64906 100644 --- a/internal/flow/flows.sql.gen.go +++ b/internal/flow/flows.sql.gen.go @@ -11,9 +11,10 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const createFlow = `-- name: CreateFlow :execrows +const createFlow = `-- name: CreateFlow :one INSERT INTO flows(product_id, name, description, created_at, updated_at) VALUES($1, $2, $3, $4, $4) +RETURNING id, product_id, name, description, created_at, updated_at ` type CreateFlowParams struct { @@ -23,17 +24,23 @@ type CreateFlowParams struct { TimeStamp pgtype.Timestamptz `json:"time_stamp"` } -func (q *Queries) CreateFlow(ctx context.Context, arg CreateFlowParams) (int64, error) { - result, err := q.db.Exec(ctx, createFlow, +func (q *Queries) CreateFlow(ctx context.Context, arg CreateFlowParams) (Flow, error) { + row := q.db.QueryRow(ctx, createFlow, arg.ProductID, arg.Name, arg.Description, arg.TimeStamp, ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil + var i Flow + err := row.Scan( + &i.ID, + &i.ProductID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err } const createFlowStep = `-- name: CreateFlowStep :execrows diff --git a/internal/flow/handler.go b/internal/flow/handler.go index 1e43b9c..5b4cb31 100644 --- a/internal/flow/handler.go +++ b/internal/flow/handler.go @@ -1,5 +1,19 @@ package flow +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "products/internal" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + func NewHandler(db DBTX) Handler { queries := &Queries{ db: db, @@ -12,3 +26,45 @@ func NewHandler(db DBTX) Handler { type flowHandler struct { queries Querier } + +func (h *flowHandler) CreateFlow(w http.ResponseWriter, r *http.Request) { + id, ok := internal.GetIntFromRequestPath("id", r) + if !ok { + internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Invalid product ID"}, http.StatusBadRequest) + return + } + req := &createFlowRequest{} + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Invalid request body"}, http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Name) == "" { + internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Name is required"}, http.StatusBadRequest) + return + } + contextWithTimeOut, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + flow, err := h.queries.CreateFlow(contextWithTimeOut, req.ToParams(id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Product not found"}, http.StatusNotFound) + return + } + internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Failed to create flow"}, http.StatusInternalServerError) + return + } + + var buf bytes.Buffer + err = json.NewEncoder(&buf).Encode(flow) + if err != nil { + internal.HandleHttpError(w, internal.ErrorEnvelope{Detail: "Failed to encode response"}, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, err = w.Write(buf.Bytes()) + if err != nil { + slog.Error("Failed to write response", "request", r.URL.Path, "error", err) + } +} diff --git a/internal/flow/handler_test.go b/internal/flow/handler_test.go new file mode 100644 index 0000000..c84a672 --- /dev/null +++ b/internal/flow/handler_test.go @@ -0,0 +1,201 @@ +package flow + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/jackc/pgx/v5" +) + +type mockFlowQuerier struct { + createFlowFunc func(ctx context.Context, arg CreateFlowParams) (Flow, error) + createFlowStepFunc func(ctx context.Context, arg CreateFlowStepParams) (int64, error) + deleteFlowFunc func(ctx context.Context, id int) (int64, error) + deleteFlowStepFunc func(ctx context.Context, id int) (int64, error) + getFlowFunc func(ctx context.Context, id int) (Flow, error) + getFlowStepsFunc func(ctx context.Context, flowID int) ([]FlowStep, error) + getFlowsByProductFunc func(ctx context.Context, productID int) ([]GetFlowsByProductRow, error) + updateFlowFunc func(ctx context.Context, arg UpdateFlowParams) (int64, error) +} + +func (m *mockFlowQuerier) CreateFlow(ctx context.Context, arg CreateFlowParams) (Flow, error) { + return m.createFlowFunc(ctx, arg) +} + +func (m *mockFlowQuerier) CreateFlowStep(ctx context.Context, arg CreateFlowStepParams) (int64, error) { + return m.createFlowStepFunc(ctx, arg) +} + +func (m *mockFlowQuerier) DeleteFlow(ctx context.Context, id int) (int64, error) { + return m.deleteFlowFunc(ctx, id) +} + +func (m *mockFlowQuerier) DeleteFlowStep(ctx context.Context, id int) (int64, error) { + return m.deleteFlowStepFunc(ctx, id) +} + +func (m *mockFlowQuerier) GetFlow(ctx context.Context, id int) (Flow, error) { + return m.getFlowFunc(ctx, id) +} + +func (m *mockFlowQuerier) GetFlowSteps(ctx context.Context, flowID int) ([]FlowStep, error) { + return m.getFlowStepsFunc(ctx, flowID) +} + +func (m *mockFlowQuerier) GetFlowsByProduct(ctx context.Context, productID int) ([]GetFlowsByProductRow, error) { + return m.getFlowsByProductFunc(ctx, productID) +} + +func (m *mockFlowQuerier) UpdateFlow(ctx context.Context, arg UpdateFlowParams) (int64, error) { + return m.updateFlowFunc(ctx, arg) +} + +func TestCreateFlow(t *testing.T) { + tests := []struct { + name string + requestBody any + pathID string + mockSetup func(m *mockFlowQuerier) + expectedStatus int + }{ + { + name: "Success", + requestBody: createFlowRequest{ + Name: "Test Flow", + }, + pathID: "1", + mockSetup: func(m *mockFlowQuerier) { + m.createFlowFunc = func(ctx context.Context, arg CreateFlowParams) (Flow, error) { + if arg.Name != "Test Flow" { + return Flow{}, errors.New("unexpected name") + } + if arg.ProductID != 1 { + return Flow{}, errors.New("unexpected product id") + } + return Flow{ID: 1, Name: arg.Name, ProductID: arg.ProductID}, nil + } + }, + expectedStatus: http.StatusCreated, + }, + { + name: "Invalid JSON", + requestBody: "invalid json", + pathID: "1", + mockSetup: func(m *mockFlowQuerier) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Missing Name", + requestBody: createFlowRequest{}, + pathID: "1", + mockSetup: func(m *mockFlowQuerier) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Database Failure", + requestBody: createFlowRequest{ + Name: "Fail Flow", + }, + pathID: "1", + mockSetup: func(m *mockFlowQuerier) { + m.createFlowFunc = func(ctx context.Context, arg CreateFlowParams) (Flow, error) { + return Flow{}, errors.New("db error") + } + }, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "Context Timeout Verification", + requestBody: createFlowRequest{ + Name: "Timeout Flow", + }, + pathID: "1", + mockSetup: func(m *mockFlowQuerier) { + m.createFlowFunc = func(ctx context.Context, arg CreateFlowParams) (Flow, error) { + deadline, ok := ctx.Deadline() + if !ok { + return Flow{}, errors.New("deadline not set") + } + diff := time.Until(deadline) + if diff < 4900*time.Millisecond || diff > 5100*time.Millisecond { + return Flow{}, errors.New("deadline not approximately 5s") + } + return Flow{ID: 1, Name: arg.Name, ProductID: arg.ProductID}, nil + } + }, + expectedStatus: http.StatusCreated, + }, + { + name: "Invalid Product ID", + requestBody: createFlowRequest{ + Name: "Invalid ID Flow", + }, + pathID: "invalid", + mockSetup: func(m *mockFlowQuerier) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Product Not Found", + requestBody: createFlowRequest{ + Name: "Missing Product Flow", + }, + pathID: "999", + mockSetup: func(m *mockFlowQuerier) { + m.createFlowFunc = func(ctx context.Context, arg CreateFlowParams) (Flow, error) { + return Flow{}, pgx.ErrNoRows + } + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockFlowQuerier{} + if tt.mockSetup != nil { + tt.mockSetup(mock) + } + h := &flowHandler{queries: mock} + + var body []byte + if s, ok := tt.requestBody.(string); ok { + body = []byte(s) + } else { + var err error + body, err = json.Marshal(tt.requestBody) + if err != nil { + t.Fatalf("json.Marshal requestBody failed: %v", err) + } + } + + req := httptest.NewRequest(http.MethodPost, "/flows", bytes.NewBuffer(body)) + req.SetPathValue("id", tt.pathID) + rr := httptest.NewRecorder() + + h.CreateFlow(rr, req) + + if rr.Code != tt.expectedStatus { + t.Errorf("expected status %v, got %v", tt.expectedStatus, rr.Code) + } + + if tt.expectedStatus == http.StatusCreated { + var flow Flow + if err := json.NewDecoder(rr.Body).Decode(&flow); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if flow.ID == 0 { + t.Error("expected flow ID to be set") + } + if rr.Header().Get("Content-Type") != "application/json" { + t.Errorf("expected Content-Type application/json, got %v", rr.Header().Get("Content-Type")) + } + } + }) + } +} diff --git a/internal/flow/interface.go b/internal/flow/interface.go index 7d2b538..d00829d 100644 --- a/internal/flow/interface.go +++ b/internal/flow/interface.go @@ -1,4 +1,7 @@ package flow +import "net/http" + type Handler interface { + CreateFlow(w http.ResponseWriter, r *http.Request) } diff --git a/internal/flow/querier.gen.go b/internal/flow/querier.gen.go index 965850a..479c45f 100644 --- a/internal/flow/querier.gen.go +++ b/internal/flow/querier.gen.go @@ -9,7 +9,7 @@ import ( ) type Querier interface { - CreateFlow(ctx context.Context, arg CreateFlowParams) (int64, error) + CreateFlow(ctx context.Context, arg CreateFlowParams) (Flow, error) CreateFlowStep(ctx context.Context, arg CreateFlowStepParams) (int64, error) DeleteFlow(ctx context.Context, id int) (int64, error) DeleteFlowStep(ctx context.Context, id int) (int64, error) diff --git a/internal/flow/request.go b/internal/flow/request.go new file mode 100644 index 0000000..ee65a16 --- /dev/null +++ b/internal/flow/request.go @@ -0,0 +1,27 @@ +package flow + +import ( + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +type createFlowRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +func (r *createFlowRequest) ToParams(productID int) CreateFlowParams { + return CreateFlowParams{ + Name: r.Name, + ProductID: productID, + Description: pgtype.Text{ + Valid: r.Description != "", + String: r.Description, + }, + TimeStamp: pgtype.Timestamptz{ + Valid: true, + Time: time.Now().UTC(), + }, + } +} diff --git a/internal/flow/request_test.go b/internal/flow/request_test.go new file mode 100644 index 0000000..2641ff5 --- /dev/null +++ b/internal/flow/request_test.go @@ -0,0 +1,27 @@ +package flow + +import ( + "testing" +) + +func TestCreateFlowRequest_ToParams(t *testing.T) { + req := createFlowRequest{ + Name: "Test Flow", + Description: "Test Description", + } + productID := 123 + params := req.ToParams(productID) + + if params.Name != req.Name { + t.Errorf("expected Name %v, got %v", req.Name, params.Name) + } + if params.ProductID != productID { + t.Errorf("expected ProductID %v, got %v", productID, params.ProductID) + } + if !params.Description.Valid || params.Description.String != req.Description { + t.Errorf("expected Description %v, got %v", req.Description, params.Description.String) + } + if !params.TimeStamp.Valid { + t.Error("expected TimeStamp to be valid") + } +} diff --git a/router/router.go b/router/router.go index 10413ea..62f24ac 100644 --- a/router/router.go +++ b/router/router.go @@ -5,6 +5,7 @@ import ( "net/http" "products/internal" "products/internal/db" + "products/internal/flow" "products/internal/platform" "products/internal/product" "products/internal/system" @@ -30,8 +31,31 @@ func SetupRouter(dbConn db.DBTX) http.Handler { })) registerSystemCallHandler(router) - registerPlatformCallHandler(platform.NewPlatformHandler(dbConn), router) - registerProductCallHandler(product.NewProductHandler(dbConn), router) + platformHandler := platform.NewPlatformHandler(dbConn) + productHandler := product.NewProductHandler(dbConn) + flowHandler := flow.NewHandler(dbConn) + + router.Route("/api/platforms", func(u chi.Router) { + u.Post("/", platformHandler.CreatePlatform) + u.Get("/", platformHandler.GetPlatforms) + u.Route("/{id}", func(u chi.Router) { + u.Get("/", platformHandler.GetPlatform) + u.Delete("/", platformHandler.DeletePlatform) + u.Put("/", platformHandler.UpdatePlatform) + u.Get("/products", productHandler.GetProductsByPlatform) + }) + + }) + router.Route("/api/products", func(u chi.Router) { + u.Post("/", productHandler.CreateProduct) + u.Route("/{id}", func(u chi.Router) { + u.Get("/", productHandler.GetProductById) + u.Delete("/", productHandler.DeleteProduct) + u.Put("/", productHandler.UpdateProduct) + u.Post("/flows", flowHandler.CreateFlow) + }) + + }) slog.Debug("Router setup complete") return router } @@ -42,22 +66,6 @@ func registerSystemCallHandler(r *chi.Mux) { r.Get("/api/version", h.GetVersion) } -func registerPlatformCallHandler(handler platform.Handler, r *chi.Mux) { - r.Route("/api/platforms", func(u chi.Router) { - u.Post("/", handler.CreatePlatform) - u.Get("/", handler.GetPlatforms) - u.Get("/{id}", handler.GetPlatform) - u.Delete("/{id}", handler.DeletePlatform) - u.Put("/{id}", handler.UpdatePlatform) - }) -} +func registerPlatformCallHandler(platformHandler platform.Handler, r *chi.Mux) { -func registerProductCallHandler(handler product.Handler, r *chi.Mux) { - r.Route("/api/products", func(u chi.Router) { - u.Post("/", handler.CreateProduct) - u.Get("/{id}", handler.GetProductById) - u.Delete("/{id}", handler.DeleteProduct) - u.Put("/{id}", handler.UpdateProduct) - }) - r.Get("/api/platforms/{platform_id}/products", handler.GetProductsByPlatform) }