From 330bc0b622a825c0fd1c6f8806082e2ea97af782 Mon Sep 17 00:00:00 2001 From: Jonathan Davies Date: Thu, 2 Oct 2025 16:57:04 +0100 Subject: [PATCH 1/5] fix: also add single post for capabiity --- .../api/handler/oscal/component_definition.go | 208 ++++++++++++++- .../component_definition_integration_test.go | 236 ++++++++++++++++++ 2 files changed, 443 insertions(+), 1 deletion(-) diff --git a/internal/api/handler/oscal/component_definition.go b/internal/api/handler/oscal/component_definition.go index 9ba0e1d5..645cf96a 100644 --- a/internal/api/handler/oscal/component_definition.go +++ b/internal/api/handler/oscal/component_definition.go @@ -43,6 +43,7 @@ func (h *ComponentDefinitionHandler) Register(api *echo.Group) { api.PUT("/:id/import-component-definitions", h.UpdateImportComponentDefinitions) // to test api.GET("/:id/components", h.GetComponents) // manually tested api.POST("/:id/components", h.CreateComponents) // integration tested + api.POST("/:id/component", h.CreateComponent) // integration tested api.PUT("/:id/components", h.UpdateComponents) // integration tested api.GET("/:id/components/:defined-component", h.GetDefinedComponent) // manually tested api.POST("/:id/components/:defined-component", h.CreateDefinedComponent) // integration tested @@ -59,6 +60,7 @@ func (h *ComponentDefinitionHandler) Register(api *echo.Group) { // api.PUT("/:id/components/:defined-component/control-implementations/:statement", h.UpdateSingleStatement) api.GET("/:id/capabilities", h.GetCapabilities) // manually tested api.POST("/:id/capabilities", h.CreateCapabilities) // integration tested + api.POST("/:id/capability", h.CreateCapability) // integration tested api.PUT("/:id/capabilities/:capability", h.UpdateCapability) // integration tested api.GET("/:id/capabilities/incorporates-components", h.GetIncorporatesComponents) // manually tested api.POST("/:id/capabilities/incorporates-components", h.CreateIncorporatesComponents) // integration tested @@ -689,6 +691,105 @@ func (h *ComponentDefinitionHandler) CreateComponents(ctx echo.Context) error { }) } +// CreateComponent godoc +// +// @Summary Create a component for a component definition +// @Description Creates a new component for a given component definition. +// @Tags Component Definitions +// @Accept json +// @Produce json +// @Param id path string true "Component definition ID" +// @Param component body oscalTypes_1_1_3.DefinedComponent true "Component to create" +// @Success 200 {object} handler.GenericDataResponse[oscalTypes_1_1_3.DefinedComponent] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/component-definitions/{id}/component [post] +func (h *ComponentDefinitionHandler) CreateComponent(ctx echo.Context) error { + idParam := ctx.Param("id") + id, err := uuid.Parse(idParam) + if err != nil { + h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var componentDefinition relational.ComponentDefinition + if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var component oscalTypes_1_1_3.DefinedComponent + if err := ctx.Bind(&component); err != nil { + h.sugar.Warnw("Failed to bind component", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Validate required fields for the component + if component.UUID == "" { + component.UUID = uuid.NewString() + } + + if component.Type == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("component type is required"))) + } + if component.Title == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("component title is required"))) + } + if component.Description == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("component description is required"))) + } + if component.Purpose == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("component purpose is required"))) + } + + // Begin a transaction + tx := h.db.Begin() + if tx.Error != nil { + h.sugar.Errorf("Failed to begin transaction: %v", tx.Error) + return ctx.JSON(http.StatusInternalServerError, api.NewError(tx.Error)) + } + + // Convert to relational model + newComponent := relational.DefinedComponent{} + newComponent.UnmarshalOscal(component) + newComponent.ComponentDefinitionID = &id + + // Create component + if err := tx.Create(&newComponent).Error; err != nil { + tx.Rollback() + h.sugar.Errorf("Failed to create component: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + // Update metadata + now := time.Now() + metadataUpdates := &relational.Metadata{ + LastModified: &now, + OscalVersion: versioning.GetLatestSupportedVersion(), + } + if err := tx.Model(&relational.Metadata{}).Where("id = ?", componentDefinition.Metadata.ID).Updates(metadataUpdates).Error; err != nil { + tx.Rollback() + h.sugar.Errorf("Failed to update metadata: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + // Commit the transaction + if err := tx.Commit().Error; err != nil { + h.sugar.Errorf("Failed to commit transaction: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + fmt.Println("Returning data: ", component) + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.DefinedComponent]{ + Data: component, + }) +} + // UpdateComponents godoc // // @Summary Update components for a component definition @@ -1681,6 +1782,111 @@ func (h *ComponentDefinitionHandler) CreateCapabilities(ctx echo.Context) error }) } +// CreateCapability godoc +// +// @Summary Create a capability for a component definition +// @Description Creates a new capability for a given component definition. +// @Tags Component Definitions +// @Accept json +// @Produce json +// @Param id path string true "Component Definition ID" +// @Param capability body oscalTypes_1_1_3.Capability true "Capability" +// @Success 200 {object} handler.GenericDataResponse[oscalTypes_1_1_3.Capability] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /oscal/component-definitions/{id}/capability [post] +func (h *ComponentDefinitionHandler) CreateCapability(ctx echo.Context) error { + idParam := ctx.Param("id") + id, err := uuid.Parse(idParam) + if err != nil { + h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var componentDefinition relational.ComponentDefinition + if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var capability oscalTypes_1_1_3.Capability + if err := ctx.Bind(&capability); err != nil { + h.sugar.Warnw("Failed to bind capabilities", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Validate required fields + if capability.UUID == "" { + capability.UUID = uuid.NewString() + } + if capability.Name == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("capability name is required"))) + } + if capability.Description == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("capability description is required"))) + } + if capability.ControlImplementations != nil { + for _, impl := range *capability.ControlImplementations { + if impl.Description == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("control implementation description is required"))) + } + for _, req := range impl.ImplementedRequirements { + if req.ControlId == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("control ID is required"))) + } + } + } + } + if capability.IncorporatesComponents != nil { + for _, component := range *capability.IncorporatesComponents { + if component.ComponentUuid == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("component UUID is required for incorporates component"))) + } + if component.Description == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("description is required for incorporates component"))) + } + } + } + + // Begin a transaction + tx := h.db.Begin() + if tx.Error != nil { + h.sugar.Errorf("Failed to begin transaction: %v", tx.Error) + return ctx.JSON(http.StatusInternalServerError, api.NewError(tx.Error)) + } + + // Convert to relational model + var newCapabilities []relational.Capability + + relationalCapability := relational.Capability{} + relationalCapability.UnmarshalOscal(capability) + relationalCapability.ComponentDefinitionId = id + newCapabilities = append(newCapabilities, relationalCapability) + + // Create the capabilities + if err := tx.Create(&relationalCapability).Error; err != nil { + tx.Rollback() + h.sugar.Errorf("Failed to create capability: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + // Commit the transaction + if err := tx.Commit().Error; err != nil { + h.sugar.Errorf("Failed to commit transaction: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.Capability]{ + Data: capability, + }) +} + // GetIncorporatesComponents godoc // // @Summary Get incorporates components for a component definition @@ -1845,7 +2051,7 @@ func (h *ComponentDefinitionHandler) GetBackMatter(ctx echo.Context) error { return ctx.JSON(http.StatusBadRequest, api.NewError(err)) } - //handler.GenericDataResponse[struct { + // handler.GenericDataResponse[struct { // UUID uuid.UUID `json:"uuid"` // Metadata relational.Metadata `json:"metadata"` // }]{} diff --git a/internal/api/handler/oscal/component_definition_integration_test.go b/internal/api/handler/oscal/component_definition_integration_test.go index 0ad95b00..44f0d875 100644 --- a/internal/api/handler/oscal/component_definition_integration_test.go +++ b/internal/api/handler/oscal/component_definition_integration_test.go @@ -367,6 +367,115 @@ func (suite *ComponentDefinitionApiIntegrationSuite) TestCreateComponents() { }) } +func (suite *ComponentDefinitionApiIntegrationSuite) TestCreateComponent() { + fmt.Println("Running TestCreateComponent") + + suite.Run("Successfully creates a component for a component definition", func() { + // First create a base component definition to add components to + componentDefID := suite.createBaseComponentDefinition() + fmt.Println("Component def id:", componentDefID) + + // Create test components + component := oscaltypes.DefinedComponent{ + UUID: uuid.New().String(), + Type: "software", + Title: "Web Server Component", + Description: "A web server component for testing", + Purpose: "Web serving", + Protocols: &[]oscaltypes.Protocol{ + { + UUID: uuid.New().String(), + Name: "https", + Title: "HTTPS Protocol", + PortRanges: &[]oscaltypes.PortRange{ + { + Start: 443, + End: 443, + Transport: "TCP", + }, + }, + }, + }, + } + // Send POST request to create components + rec, req := suite.createRequest( + http.MethodPost, + fmt.Sprintf("/api/oscal/component-definitions/%s/component", componentDefID), + component, + ) + suite.server.E().ServeHTTP(rec, req) + + // Check response + suite.Equal(http.StatusOK, rec.Code, "Failed to create component") + + // Unmarshal and verify response + componentResponse := &handler.GenericDataResponse[oscaltypes.DefinedComponent]{} + err := json.Unmarshal(rec.Body.Bytes(), componentResponse) + suite.Require().NoError(err, "Failed to unmarshal components response") + + // Verify each component + suite.Equal(componentResponse.Data.Type, component.Type, "Component type doesn't match") + suite.Equal(componentResponse.Data.Title, component.Title, "Component title doesn't match") + suite.Equal(componentResponse.Data.Description, component.Description, "Component description doesn't match") + suite.Equal(componentResponse.Data.Purpose, component.Purpose, "Component purpose doesn't match") + + fmt.Printf("Successfully created component for component definition %s\n", componentDefID) + + // Verify we can retrieve the components + rec, req = suite.createRequest( + http.MethodGet, + fmt.Sprintf("/api/oscal/component-definitions/%s/components", componentDefID), + nil, + ) + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusOK, rec.Code, "Failed to get components") + + getResponse := &handler.GenericDataListResponse[oscaltypes.DefinedComponent]{} + err = json.Unmarshal(rec.Body.Bytes(), getResponse) + suite.Require().NoError(err, "Failed to unmarshal GET response") + suite.Equal(1, len(getResponse.Data), "Number of retrieved components doesn't match") + }) + + suite.Run("Fails to create component for non-existent component definition", func() { + nonExistentID := uuid.New().String() + component := oscaltypes.DefinedComponent{ + Type: "software", + Title: "Test Component", + Description: "A test component", + Purpose: "Testing", + } + + rec, req := suite.createRequest( + http.MethodPost, + fmt.Sprintf("/api/oscal/component-definitions/%s/component", nonExistentID), + component, + ) + suite.server.E().ServeHTTP(rec, req) + + suite.Equal(http.StatusNotFound, rec.Code, "Expected 404 for non-existent component definition") + }) + + suite.Run("Fails to create components with invalid data", func() { + componentDefID := suite.createBaseComponentDefinition() + + // Create invalid component (missing required fields) + invalidComponent := oscaltypes.DefinedComponent{ + UUID: uuid.New().String(), + // Missing required fields like Type, Title, etc. + + } + + rec, req := suite.createRequest( + http.MethodPost, + fmt.Sprintf("/api/oscal/component-definitions/%s/component", componentDefID), + invalidComponent, + ) + suite.server.E().ServeHTTP(rec, req) + + suite.Equal(http.StatusBadRequest, rec.Code, "Expected 400 for invalid component data") + }) +} + func (suite *ComponentDefinitionApiIntegrationSuite) TestUpdateComponents() { fmt.Println("Running TestUpdateComponents") @@ -1188,6 +1297,133 @@ func (suite *ComponentDefinitionApiIntegrationSuite) TestCreateCapabilities() { }) } +func (suite *ComponentDefinitionApiIntegrationSuite) TestCreateCapability() { + fmt.Println("Running TestCreateCapability") + + suite.Run("Successfully creates capability for a component definition", func() { + // Step 1: Create a base component definition + componentDefID := suite.createBaseComponentDefinition() + + // Step 2: Prepare capabilities to create + capability := oscaltypes.Capability{ + UUID: uuid.New().String(), + Name: "Security Monitoring", + Description: "Security monitoring capability", + ControlImplementations: &[]oscaltypes.ControlImplementationSet{ + { + UUID: uuid.New().String(), + Description: "Security monitoring control implementation", + ImplementedRequirements: []oscaltypes.ImplementedRequirementControlImplementation{ + { + UUID: uuid.New().String(), + ControlId: "SI-4", + Remarks: "Information system monitoring", + }, + }, + }, + }, + } + + // Step 3: Send POST request to create capabilities + rec, req := suite.createRequest( + http.MethodPost, + fmt.Sprintf("/api/oscal/component-definitions/%s/capability", componentDefID), + capability, + ) + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusOK, rec.Code, "Failed to create capability") + + // Step 4: Verify the creation in the response + response := &handler.GenericDataResponse[oscaltypes.Capability]{} + err := json.Unmarshal(rec.Body.Bytes(), response) + suite.Require().NoError(err, "Failed to unmarshal creation response") + + // Verify each capability was created correctly + + suite.Equal(response.Data.UUID, capability.UUID, "Capability UUID doesn't match") + suite.Equal(response.Data.Name, capability.Name, "Capability name doesn't match") + suite.Equal(response.Data.Description, capability.Description, "Capability description doesn't match") + suite.Require().NotNil(response.Data.ControlImplementations, "Control implementations should not be nil") + suite.Equal(len(*response.Data.ControlImplementations), len(*capability.ControlImplementations), "Number of control implementations doesn't match") + suite.Equal((*response.Data.ControlImplementations)[0].Description, (*capability.ControlImplementations)[0].Description, "Control implementation description doesn't match") + suite.Equal((*response.Data.ControlImplementations)[0].ImplementedRequirements[0].ControlId, (*capability.ControlImplementations)[0].ImplementedRequirements[0].ControlId, "Control ID doesn't match") + suite.Equal((*response.Data.ControlImplementations)[0].ImplementedRequirements[0].Remarks, (*capability.ControlImplementations)[0].ImplementedRequirements[0].Remarks, "Remarks don't match") + + fmt.Printf("Successfully created capabilities for component definition %s\n", componentDefID) + + // Step 5: Verify the creations persist by retrieving the capabilities + rec, req = suite.createRequest( + http.MethodGet, + fmt.Sprintf("/api/oscal/component-definitions/%s/capabilities", componentDefID), + nil, + ) + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusOK, rec.Code, "Failed to get capabilities") + + getResponse := &handler.GenericDataListResponse[oscaltypes.Capability]{} + err = json.Unmarshal(rec.Body.Bytes(), getResponse) + suite.Require().NoError(err, "Failed to unmarshal GET response") + + // Verify the retrieved capabilities match the creations + suite.Equal(1, len(getResponse.Data), "Number of retrieved capabilities doesn't match") + for _, getCapability := range getResponse.Data { + suite.Equal(capability.UUID, getCapability.UUID, "Retrieved capability UUID doesn't match") + suite.Equal(capability.Name, getCapability.Name, "Retrieved capability name doesn't match") + suite.Equal(capability.Description, getCapability.Description, "Retrieved capability description doesn't match") + } + }) + + suite.Run("Fails to create capabilities for non-existent component definition", func() { + nonExistentID := uuid.New().String() + capability := oscaltypes.Capability{ + UUID: uuid.New().String(), + Name: "Test Capability", + Description: "A test capability", + } + + rec, req := suite.createRequest( + http.MethodPost, + fmt.Sprintf("/api/oscal/component-definitions/%s/capability", nonExistentID), + capability, + ) + suite.server.E().ServeHTTP(rec, req) + + suite.Equal(http.StatusNotFound, rec.Code, "Expected 404 for non-existent component definition") + }) + + suite.Run("Fails to create capabilities with invalid data", func() { + componentDefID := suite.createBaseComponentDefinition() + + // Create invalid capability with empty required fields + invalidCapabilities := oscaltypes.Capability{ + UUID: uuid.New().String(), + Name: "", // Empty name should be invalid + Description: "", // Empty description should be invalid + ControlImplementations: &[]oscaltypes.ControlImplementationSet{ + { + UUID: uuid.New().String(), + Description: "", // Empty description should be invalid + ImplementedRequirements: []oscaltypes.ImplementedRequirementControlImplementation{ + { + UUID: uuid.New().String(), + ControlId: "", // Empty control ID should be invalid + }, + }, + }, + }, + } + + rec, req := suite.createRequest( + http.MethodPost, + fmt.Sprintf("/api/oscal/component-definitions/%s/capability", componentDefID), + invalidCapabilities, + ) + suite.server.E().ServeHTTP(rec, req) + + suite.Equal(http.StatusBadRequest, rec.Code, "Expected 400 for invalid capability data") + }) +} + func (suite *ComponentDefinitionApiIntegrationSuite) TestUpdateImportComponentDefinitions() { fmt.Println("Running TestUpdateImportComponentDefinitions") From f393170121d32e44d148ea71d478cd97b1f2b47d Mon Sep 17 00:00:00 2001 From: Jonathan Davies Date: Thu, 2 Oct 2025 17:34:57 +0100 Subject: [PATCH 2/5] fix: update documentation --- docs/docs.go | 140 ++++++++++++++++++ docs/swagger.json | 140 ++++++++++++++++++ docs/swagger.yaml | 90 +++++++++++ .../api/handler/oscal/component_definition.go | 14 +- 4 files changed, 377 insertions(+), 7 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 94972f9a..602bbcde 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -7542,6 +7542,146 @@ const docTemplate = `{ } } }, + "/oscal/component-definitions/{id}/capability": { + "post": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Creates a new capability for a given component definition.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Component Definitions" + ], + "summary": "Create a capability for a component definition", + "parameters": [ + { + "type": "string", + "description": "Component Definition ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Capability", + "name": "capability", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.Capability" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Capability" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + } + } + }, + "/oscal/component-definitions/{id}/component": { + "post": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Creates a new component for a given component definition.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Component Definitions" + ], + "summary": "Create a component for a component definition", + "parameters": [ + { + "type": "string", + "description": "Component definition ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Component to create", + "name": "component", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.DefinedComponent" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_DefinedComponent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + } + } + }, "/oscal/component-definitions/{id}/components": { "get": { "security": [ diff --git a/docs/swagger.json b/docs/swagger.json index d924961b..ca7fc7e6 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -7536,6 +7536,146 @@ } } }, + "/oscal/component-definitions/{id}/capability": { + "post": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Creates a new capability for a given component definition.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Component Definitions" + ], + "summary": "Create a capability for a component definition", + "parameters": [ + { + "type": "string", + "description": "Component Definition ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Capability", + "name": "capability", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.Capability" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Capability" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + } + } + }, + "/oscal/component-definitions/{id}/component": { + "post": { + "security": [ + { + "OAuth2Password": [] + } + ], + "description": "Creates a new component for a given component definition.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Component Definitions" + ], + "summary": "Create a component for a component definition", + "parameters": [ + { + "type": "string", + "description": "Component definition ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Component to create", + "name": "component", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.DefinedComponent" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_DefinedComponent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + } + } + }, "/oscal/component-definitions/{id}/components": { "get": { "security": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e7720cb5..8841fa47 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -10420,6 +10420,96 @@ paths: summary: Create incorporates components for a component definition tags: - Component Definitions + /oscal/component-definitions/{id}/capability: + post: + consumes: + - application/json + description: Creates a new capability for a given component definition. + parameters: + - description: Component Definition ID + in: path + name: id + required: true + type: string + - description: Capability + in: body + name: capability + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.Capability' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_Capability' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Create a capability for a component definition + tags: + - Component Definitions + /oscal/component-definitions/{id}/component: + post: + consumes: + - application/json + description: Creates a new component for a given component definition. + parameters: + - description: Component definition ID + in: path + name: id + required: true + type: string + - description: Component to create + in: body + name: component + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.DefinedComponent' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_DefinedComponent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Create a component for a component definition + tags: + - Component Definitions /oscal/component-definitions/{id}/components: get: description: Retrieves all components for a given component definition. diff --git a/internal/api/handler/oscal/component_definition.go b/internal/api/handler/oscal/component_definition.go index 645cf96a..beb3480a 100644 --- a/internal/api/handler/oscal/component_definition.go +++ b/internal/api/handler/oscal/component_definition.go @@ -1789,13 +1789,13 @@ func (h *ComponentDefinitionHandler) CreateCapabilities(ctx echo.Context) error // @Tags Component Definitions // @Accept json // @Produce json -// @Param id path string true "Component Definition ID" -// @Param capability body oscalTypes_1_1_3.Capability true "Capability" -// @Success 200 {object} handler.GenericDataResponse[oscalTypes_1_1_3.Capability] -// @Failure 400 {object} api.Error -// @Failure 401 {object} api.Error -// @Failure 404 {object} api.Error -// @Failure 500 {object} api.Error +// @Param id path string true "Component Definition ID" +// @Param capability body oscalTypes_1_1_3.Capability true "Capability" +// @Success 200 {object} handler.GenericDataResponse[oscalTypes_1_1_3.Capability] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/capability [post] func (h *ComponentDefinitionHandler) CreateCapability(ctx echo.Context) error { From c32a980c2fa1063dad86261f47c0e105a49f6ecc Mon Sep 17 00:00:00 2001 From: Jonathan Davies Date: Fri, 3 Oct 2025 16:23:30 +0100 Subject: [PATCH 3/5] fix: preload metadata with shared func to update properly --- internal/api/error.go | 11 + .../api/handler/oscal/component_definition.go | 332 +++++------------- .../component_definition_integration_test.go | 23 +- 3 files changed, 104 insertions(+), 262 deletions(-) diff --git a/internal/api/error.go b/internal/api/error.go index e2e38f84..b17c1500 100644 --- a/internal/api/error.go +++ b/internal/api/error.go @@ -12,6 +12,17 @@ import ( type Error struct { Errors map[string]any `json:"errors" yaml:"errors"` } +type HTTPError struct { + StatusCode int + Err error +} + +func NewHTTPError(status int, err error) HTTPError { + return HTTPError{ + StatusCode: status, + Err: err, + } +} func NewError(err error) Error { e := Error{} diff --git a/internal/api/handler/oscal/component_definition.go b/internal/api/handler/oscal/component_definition.go index beb3480a..6700da80 100644 --- a/internal/api/handler/oscal/component_definition.go +++ b/internal/api/handler/oscal/component_definition.go @@ -32,6 +32,28 @@ func NewComponentDefinitionHandler(sugar *zap.SugaredLogger, db *gorm.DB) *Compo } } +func (h *ComponentDefinitionHandler) getExistingComponentDefinition(ctx echo.Context) (*relational.ComponentDefinition, *api.HTTPError) { + // Utility function for grabbing an existing component definition + idParam := ctx.Param("id") + componentDefinitionId, err := uuid.Parse(idParam) + if err != nil { + h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) + httpErr := api.NewHTTPError(http.StatusBadRequest, err) + return nil, &httpErr + } + var componentDefinition relational.ComponentDefinition + if err := h.db.First(&componentDefinition, "id = ?", componentDefinitionId).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + httpError := api.NewHTTPError(404, err) + return nil, &httpError + } + h.sugar.Warnw("Failed to load component definition", "id", componentDefinitionId.String(), "error", err) + httpErr := api.NewHTTPError(http.StatusBadRequest, err) + return nil, &httpErr + } + return &componentDefinition, nil +} + func (h *ComponentDefinitionHandler) Register(api *echo.Group) { api.GET("", h.List) // manually tested api.POST("", h.Create) // manually tested @@ -352,22 +374,10 @@ func (h *ComponentDefinitionHandler) Full(ctx echo.Context) error { // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/import-component-definitions [get] func (h *ComponentDefinitionHandler) GetImportComponentDefinitions(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - var oscalImportComponentDefinitions []oscalTypes_1_1_3.ImportComponentDefinition for _, importComponentDefinition := range componentDefinition.ImportComponentDefinitions { oscalImportComponentDefinitions = append(oscalImportComponentDefinitions, *importComponentDefinition.MarshalOscal()) @@ -478,22 +488,10 @@ func (h *ComponentDefinitionHandler) CreateImportComponentDefinitions(ctx echo.C // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/import-component-definitions [put] func (h *ComponentDefinitionHandler) UpdateImportComponentDefinitions(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - var importComponentDefinitions []oscalTypes_1_1_3.ImportComponentDefinition if err := ctx.Bind(&importComponentDefinitions); err != nil { h.sugar.Warnw("Failed to bind import component definitions", "error", err) @@ -605,22 +603,10 @@ func (h *ComponentDefinitionHandler) GetComponents(ctx echo.Context) error { // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/components [post] func (h *ComponentDefinitionHandler) CreateComponents(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - var components []oscalTypes_1_1_3.DefinedComponent if err := ctx.Bind(&components); err != nil { h.sugar.Warnw("Failed to bind components", "error", err) @@ -655,7 +641,7 @@ func (h *ComponentDefinitionHandler) CreateComponents(ctx echo.Context) error { for _, component := range components { relationalComponent := relational.DefinedComponent{} relationalComponent.UnmarshalOscal(component) - relationalComponent.ComponentDefinitionID = &id + relationalComponent.ComponentDefinitionID = componentDefinition.ID newComponents = append(newComponents, relationalComponent) } @@ -708,20 +694,9 @@ func (h *ComponentDefinitionHandler) CreateComponents(ctx echo.Context) error { // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/component [post] func (h *ComponentDefinitionHandler) CreateComponent(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } var component oscalTypes_1_1_3.DefinedComponent @@ -758,7 +733,7 @@ func (h *ComponentDefinitionHandler) CreateComponent(ctx echo.Context) error { // Convert to relational model newComponent := relational.DefinedComponent{} newComponent.UnmarshalOscal(component) - newComponent.ComponentDefinitionID = &id + newComponent.ComponentDefinitionID = componentDefinition.ID // Create component if err := tx.Create(&newComponent).Error; err != nil { @@ -771,8 +746,9 @@ func (h *ComponentDefinitionHandler) CreateComponent(ctx echo.Context) error { now := time.Now() metadataUpdates := &relational.Metadata{ LastModified: &now, - OscalVersion: versioning.GetLatestSupportedVersion(), + OscalVersion: "test oscal version", } + if err := tx.Model(&relational.Metadata{}).Where("id = ?", componentDefinition.Metadata.ID).Updates(metadataUpdates).Error; err != nil { tx.Rollback() h.sugar.Errorf("Failed to update metadata: %v", err) @@ -784,9 +760,9 @@ func (h *ComponentDefinitionHandler) CreateComponent(ctx echo.Context) error { h.sugar.Errorf("Failed to commit transaction: %v", err) return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } - fmt.Println("Returning data: ", component) + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.DefinedComponent]{ - Data: component, + Data: *newComponent.MarshalOscal(), }) } @@ -807,22 +783,10 @@ func (h *ComponentDefinitionHandler) CreateComponent(ctx echo.Context) error { // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/components [put] func (h *ComponentDefinitionHandler) UpdateComponents(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - var oscalComponents []oscalTypes_1_1_3.DefinedComponent if err := ctx.Bind(&oscalComponents); err != nil { h.sugar.Warnw("Failed to bind components", "error", err) @@ -840,7 +804,7 @@ func (h *ComponentDefinitionHandler) UpdateComponents(ctx echo.Context) error { for _, oscalComponent := range oscalComponents { relationalComponent := relational.DefinedComponent{} relationalComponent.UnmarshalOscal(oscalComponent) - relationalComponent.ComponentDefinitionID = &id + relationalComponent.ComponentDefinitionID = componentDefinition.ID // Check if the component exists first var existingComponent relational.DefinedComponent @@ -862,7 +826,7 @@ func (h *ComponentDefinitionHandler) UpdateComponents(ctx echo.Context) error { } else { // Component exists, update it using a map instead of struct to handle zero values updateFields := map[string]any{ - "component_definition_id": id, + "component_definition_id": componentDefinition.ID, "title": relationalComponent.Title, "description": relationalComponent.Description, "purpose": relationalComponent.Purpose, @@ -981,22 +945,10 @@ func (h *ComponentDefinitionHandler) GetDefinedComponent(ctx echo.Context) error // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/components/{defined-component} [post] func (h *ComponentDefinitionHandler) CreateDefinedComponent(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - var oscalDefinedComponent oscalTypes_1_1_3.DefinedComponent if err := ctx.Bind(&oscalDefinedComponent); err != nil { h.sugar.Warnw("Failed to bind defined component", "error", err) @@ -1068,22 +1020,10 @@ func (h *ComponentDefinitionHandler) CreateDefinedComponent(ctx echo.Context) er // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/components/{defined-component} [put] func (h *ComponentDefinitionHandler) UpdateDefinedComponent(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - definedComponentID := ctx.Param("defined-component") var definedComponent relational.DefinedComponent if err := h.db.First(&definedComponent, "id = ?", definedComponentID).Error; err != nil { @@ -1109,11 +1049,11 @@ func (h *ComponentDefinitionHandler) UpdateDefinedComponent(ctx echo.Context) er // Update only the fields that are provided in the request definedComponent.UnmarshalOscal(oscalDefinedComponent) - definedComponent.ComponentDefinitionID = &id // Ensure proper association + definedComponent.ComponentDefinitionID = componentDefinition.ID // Ensure proper association // Convert struct to map for updates to properly handle zero values updateFields := map[string]any{ - "component_definition_id": id, + "component_definition_id": componentDefinition.ID, "title": definedComponent.Title, "description": definedComponent.Description, "purpose": definedComponent.Purpose, @@ -1266,22 +1206,10 @@ func (h *ComponentDefinitionHandler) GetControlImplementations(ctx echo.Context) // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/components/{defined-component}/control-implementations [post] func (h *ComponentDefinitionHandler) CreateControlImplementations(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + _, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - definedComponentID := ctx.Param("defined-component") var definedComponent relational.DefinedComponent if err := h.db.First(&definedComponent, "id = ?", definedComponentID).Error; err != nil { @@ -1347,22 +1275,10 @@ func (h *ComponentDefinitionHandler) CreateControlImplementations(ctx echo.Conte // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/components/{defined-component}/control-implementations/implemented-requirements [get] func (h *ComponentDefinitionHandler) GetImplementedRequirements(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + _, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - definedComponentID := ctx.Param("defined-component") var definedComponent relational.DefinedComponent if err := h.db. @@ -1486,22 +1402,10 @@ func (h *ComponentDefinitionHandler) GetImplementedRequirements(ctx echo.Context // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/components/{defined-component}/control-implementations/implemented-requirements/statements [get] func (h *ComponentDefinitionHandler) GetStatements(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + _, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - definedComponentID := ctx.Param("defined-component") var definedComponent relational.DefinedComponent if err := h.db. @@ -1692,22 +1596,10 @@ func (h *ComponentDefinitionHandler) GetCapabilities(ctx echo.Context) error { // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/capabilities [post] func (h *ComponentDefinitionHandler) CreateCapabilities(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - var capabilities []oscalTypes_1_1_3.Capability if err := ctx.Bind(&capabilities); err != nil { h.sugar.Warnw("Failed to bind capabilities", "error", err) @@ -1758,7 +1650,7 @@ func (h *ComponentDefinitionHandler) CreateCapabilities(ctx echo.Context) error for _, capability := range capabilities { relationalCapability := relational.Capability{} relationalCapability.UnmarshalOscal(capability) - relationalCapability.ComponentDefinitionId = id + relationalCapability.ComponentDefinitionId = *componentDefinition.ID newCapabilities = append(newCapabilities, relationalCapability) } @@ -1799,22 +1691,10 @@ func (h *ComponentDefinitionHandler) CreateCapabilities(ctx echo.Context) error // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/capability [post] func (h *ComponentDefinitionHandler) CreateCapability(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - var capability oscalTypes_1_1_3.Capability if err := ctx.Bind(&capability); err != nil { h.sugar.Warnw("Failed to bind capabilities", "error", err) @@ -1866,7 +1746,7 @@ func (h *ComponentDefinitionHandler) CreateCapability(ctx echo.Context) error { relationalCapability := relational.Capability{} relationalCapability.UnmarshalOscal(capability) - relationalCapability.ComponentDefinitionId = id + relationalCapability.ComponentDefinitionId = *componentDefinition.ID newCapabilities = append(newCapabilities, relationalCapability) // Create the capabilities @@ -1961,22 +1841,10 @@ func (h *ComponentDefinitionHandler) GetIncorporatesComponents(ctx echo.Context) // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/capabilities/incorporates-components [post] func (h *ComponentDefinitionHandler) CreateIncorporatesComponents(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + _, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - var incorporatesComponents []oscalTypes_1_1_3.IncorporatesComponent if err := ctx.Bind(&incorporatesComponents); err != nil { h.sugar.Warnw("Failed to bind incorporates components", "error", err) @@ -2076,22 +1944,10 @@ func (h *ComponentDefinitionHandler) GetBackMatter(ctx echo.Context) error { // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/back-matter [post] func (h *ComponentDefinitionHandler) CreateBackMatter(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - var backMatter oscalTypes_1_1_3.BackMatter if err := ctx.Bind(&backMatter); err != nil { h.sugar.Warnw("Failed to bind back-matter", "error", err) @@ -2174,22 +2030,10 @@ func (h *ComponentDefinitionHandler) CreateBackMatter(ctx echo.Context) error { // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/components/{defined-component}/control-implementations [put] func (h *ComponentDefinitionHandler) UpdateControlImplementations(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - definedComponentID := ctx.Param("defined-component") var definedComponent relational.DefinedComponent if err := h.db.First(&definedComponent, "id = ?", definedComponentID).Error; err != nil { @@ -2276,22 +2120,10 @@ func (h *ComponentDefinitionHandler) UpdateControlImplementations(ctx echo.Conte // @Security OAuth2Password // @Router /oscal/component-definitions/{id}/components/{defined-component}/control-implementations/{control-implementation} [put] func (h *ComponentDefinitionHandler) UpdateSingleControlImplementation(ctx echo.Context) error { - idParam := ctx.Param("id") - id, err := uuid.Parse(idParam) - if err != nil { - h.sugar.Warnw("Invalid component definition id", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) - } - - var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ctx.JSON(http.StatusNotFound, api.NewError(err)) - } - h.sugar.Warnw("Failed to load component definition", "id", idParam, "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + componentDefinition, httpErr := h.getExistingComponentDefinition(ctx) + if httpErr != nil { + return ctx.JSON(httpErr.StatusCode, api.NewError(httpErr.Err)) } - definedComponentID := ctx.Param("defined-component") var definedComponent relational.DefinedComponent if err := h.db.First(&definedComponent, "id = ?", definedComponentID).Error; err != nil { diff --git a/internal/api/handler/oscal/component_definition_integration_test.go b/internal/api/handler/oscal/component_definition_integration_test.go index 44f0d875..e16339c0 100644 --- a/internal/api/handler/oscal/component_definition_integration_test.go +++ b/internal/api/handler/oscal/component_definition_integration_test.go @@ -373,7 +373,6 @@ func (suite *ComponentDefinitionApiIntegrationSuite) TestCreateComponent() { suite.Run("Successfully creates a component for a component definition", func() { // First create a base component definition to add components to componentDefID := suite.createBaseComponentDefinition() - fmt.Println("Component def id:", componentDefID) // Create test components component := oscaltypes.DefinedComponent{ @@ -414,10 +413,10 @@ func (suite *ComponentDefinitionApiIntegrationSuite) TestCreateComponent() { suite.Require().NoError(err, "Failed to unmarshal components response") // Verify each component - suite.Equal(componentResponse.Data.Type, component.Type, "Component type doesn't match") - suite.Equal(componentResponse.Data.Title, component.Title, "Component title doesn't match") - suite.Equal(componentResponse.Data.Description, component.Description, "Component description doesn't match") - suite.Equal(componentResponse.Data.Purpose, component.Purpose, "Component purpose doesn't match") + suite.Equal(component.Type, componentResponse.Data.Type, "Component type doesn't match") + suite.Equal(component.Title, componentResponse.Data.Title, "Component title doesn't match") + suite.Equal(component.Description, componentResponse.Data.Description, "Component description doesn't match") + suite.Equal(component.Purpose, componentResponse.Data.Purpose, "Component purpose doesn't match") fmt.Printf("Successfully created component for component definition %s\n", componentDefID) @@ -1340,14 +1339,14 @@ func (suite *ComponentDefinitionApiIntegrationSuite) TestCreateCapability() { // Verify each capability was created correctly - suite.Equal(response.Data.UUID, capability.UUID, "Capability UUID doesn't match") - suite.Equal(response.Data.Name, capability.Name, "Capability name doesn't match") - suite.Equal(response.Data.Description, capability.Description, "Capability description doesn't match") + suite.Equal(capability.UUID, response.Data.UUID, "Capability UUID doesn't match") + suite.Equal(capability.Name, response.Data.Name, "Capability name doesn't match") + suite.Equal(capability.Description, response.Data.Description, "Capability description doesn't match") suite.Require().NotNil(response.Data.ControlImplementations, "Control implementations should not be nil") - suite.Equal(len(*response.Data.ControlImplementations), len(*capability.ControlImplementations), "Number of control implementations doesn't match") - suite.Equal((*response.Data.ControlImplementations)[0].Description, (*capability.ControlImplementations)[0].Description, "Control implementation description doesn't match") - suite.Equal((*response.Data.ControlImplementations)[0].ImplementedRequirements[0].ControlId, (*capability.ControlImplementations)[0].ImplementedRequirements[0].ControlId, "Control ID doesn't match") - suite.Equal((*response.Data.ControlImplementations)[0].ImplementedRequirements[0].Remarks, (*capability.ControlImplementations)[0].ImplementedRequirements[0].Remarks, "Remarks don't match") + suite.Equal(len(*capability.ControlImplementations), len(*response.Data.ControlImplementations), "Number of control implementations doesn't match") + suite.Equal((*capability.ControlImplementations)[0].Description, (*response.Data.ControlImplementations)[0].Description, "Control implementation description doesn't match") + suite.Equal((*capability.ControlImplementations)[0].ImplementedRequirements[0].ControlId, (*response.Data.ControlImplementations)[0].ImplementedRequirements[0].ControlId, "Control ID doesn't match") + suite.Equal((*capability.ControlImplementations)[0].ImplementedRequirements[0].Remarks, (*response.Data.ControlImplementations)[0].ImplementedRequirements[0].Remarks, "Remarks don't match") fmt.Printf("Successfully created capabilities for component definition %s\n", componentDefID) From 691dafba1a7a8fdd0c3a3744ab0191e2a8b1d824 Mon Sep 17 00:00:00 2001 From: Jonathan Davies Date: Fri, 3 Oct 2025 17:08:05 +0100 Subject: [PATCH 4/5] fix: return relational capability --- internal/api/handler/oscal/component_definition.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/handler/oscal/component_definition.go b/internal/api/handler/oscal/component_definition.go index 6700da80..15627fcf 100644 --- a/internal/api/handler/oscal/component_definition.go +++ b/internal/api/handler/oscal/component_definition.go @@ -1763,7 +1763,7 @@ func (h *ComponentDefinitionHandler) CreateCapability(ctx echo.Context) error { } return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.Capability]{ - Data: capability, + Data: *relationalCapability.MarshalOscal(), }) } From 938373308d8032ef5299a0fc180ebb1c4ff50cfc Mon Sep 17 00:00:00 2001 From: Jonathan Davies Date: Fri, 3 Oct 2025 18:03:17 +0100 Subject: [PATCH 5/5] fix: preload metadata --- .../api/handler/oscal/component_definition.go | 4 +- .../component_definition_integration_test.go | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/internal/api/handler/oscal/component_definition.go b/internal/api/handler/oscal/component_definition.go index 15627fcf..bc8cb1ee 100644 --- a/internal/api/handler/oscal/component_definition.go +++ b/internal/api/handler/oscal/component_definition.go @@ -42,7 +42,7 @@ func (h *ComponentDefinitionHandler) getExistingComponentDefinition(ctx echo.Con return nil, &httpErr } var componentDefinition relational.ComponentDefinition - if err := h.db.First(&componentDefinition, "id = ?", componentDefinitionId).Error; err != nil { + if err := h.db.Preload("Metadata").First(&componentDefinition, "id = ?", componentDefinitionId).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { httpError := api.NewHTTPError(404, err) return nil, &httpError @@ -746,7 +746,7 @@ func (h *ComponentDefinitionHandler) CreateComponent(ctx echo.Context) error { now := time.Now() metadataUpdates := &relational.Metadata{ LastModified: &now, - OscalVersion: "test oscal version", + OscalVersion: versioning.GetLatestSupportedVersion(), } if err := tx.Model(&relational.Metadata{}).Where("id = ?", componentDefinition.Metadata.ID).Updates(metadataUpdates).Error; err != nil { diff --git a/internal/api/handler/oscal/component_definition_integration_test.go b/internal/api/handler/oscal/component_definition_integration_test.go index e16339c0..5cdbc85c 100644 --- a/internal/api/handler/oscal/component_definition_integration_test.go +++ b/internal/api/handler/oscal/component_definition_integration_test.go @@ -14,6 +14,7 @@ import ( "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/api/handler" + "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/internal/tests" oscaltypes "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" "github.com/google/uuid" @@ -473,6 +474,71 @@ func (suite *ComponentDefinitionApiIntegrationSuite) TestCreateComponent() { suite.Equal(http.StatusBadRequest, rec.Code, "Expected 400 for invalid component data") }) + + suite.Run("Successfully updates the metadata of a component definition on component creation", func() { + // First create a base component definition to add components to + componentDefID := suite.createBaseComponentDefinition() + + var def relational.ComponentDefinition + err := suite.DB.Preload("Metadata").First(&def, "id = ?", componentDefID).Error + suite.Require().NoError(err, "failed to get component definition") + var md relational.Metadata + err = suite.DB.First(&md, "id = ?", def.Metadata.ID).Error + suite.Require().NoError(err, "failed to get metadata for component definition") + firstModified := def.Metadata.LastModified + + // Create test components + component := oscaltypes.DefinedComponent{ + UUID: uuid.New().String(), + Type: "software", + Title: "Web Server Component", + Description: "A web server component for testing", + Purpose: "Web serving", + Protocols: &[]oscaltypes.Protocol{ + { + UUID: uuid.New().String(), + Name: "https", + Title: "HTTPS Protocol", + PortRanges: &[]oscaltypes.PortRange{ + { + Start: 443, + End: 443, + Transport: "TCP", + }, + }, + }, + }, + } + + // Send POST request to create components + rec, req := suite.createRequest( + http.MethodPost, + fmt.Sprintf("/api/oscal/component-definitions/%s/component", componentDefID), + component, + ) + suite.server.E().ServeHTTP(rec, req) + + // Check response + suite.Equal(http.StatusOK, rec.Code, "Failed to create component") + + // Unmarshal and verify response + componentResponse := &handler.GenericDataResponse[oscaltypes.DefinedComponent]{} + err = json.Unmarshal(rec.Body.Bytes(), componentResponse) + suite.Require().NoError(err, "Failed to unmarshal components response") + + // Check last modified metadata is updated + + var newDef relational.ComponentDefinition + err = suite.DB.Preload("Metadata").First(&newDef, "id = ?", componentDefID).Error + suite.Require().NoError(err, "failed to get component definition") + var newMd relational.Metadata + err = suite.DB.First(&newMd, "id = ?", newDef.Metadata.ID).Error + suite.Require().NoError(err, "failed to get metadata for component definition") + recentLastModified := newDef.Metadata.LastModified + + suite.NotEqual(*firstModified, *recentLastModified) + suite.Less(*firstModified, *recentLastModified) + }) } func (suite *ComponentDefinitionApiIntegrationSuite) TestUpdateComponents() {