-
Notifications
You must be signed in to change notification settings - Fork 2
API: Build Profile by Props (imports + controls + tests) #313
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
API: Build Profile by Props (imports + controls + tests) #313
Conversation
…es in BuildByProps
…ns after create and reload full profile
…ssociation explicitly
…atisfy check-diff
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Adds an API endpoint to build an OSCAL Profile by selecting catalog controls whose props match caller-supplied rules, and updates docs/tests accordingly.
Changes:
- Adds
POST /api/oscal/profiles/build-propshandler that matches catalog controls by prop rules, persists back-matter + single import + include-controls, syncs pivots, and returns the full profile with matched control IDs. - Adds an integration test covering build-by-props behavior and validates exactly one persisted import.
- Updates generated Swagger artifacts and tweaks CI golangci-lint action cache settings.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/api/handler/oscal/profiles.go | Implements and registers BuildByProps, plus prop-matching helper logic. |
| internal/api/handler/oscal/profiles_integration_test.go | Adds integration coverage for build-by-props and removes debugging output/import. |
| docs/swagger.yaml | Adds the new endpoint to OpenAPI YAML output. |
| docs/swagger.json | Adds the new endpoint to OpenAPI JSON output. |
| docs/docs.go | Updates embedded swagger template with the new endpoint. |
| .github/workflows/ci.yml | Updates golangci-lint action cache-related inputs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "201": { | ||
| "description": "Created", | ||
| "schema": { | ||
| "$ref": "#/definitions/handler.GenericDataResponse-oscal_ProfileHandler" |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The embedded swagger template also uses handler.GenericDataResponse-oscal_ProfileHandler for the 201 response, which won’t describe the actual response fields for BuildByProps. Regenerate swagger after defining a named response type so the template reflects the real payload schema.
| "$ref": "#/definitions/handler.GenericDataResponse-oscal_ProfileHandler" | |
| "$ref": "#/definitions/oscal.ProfileHandler" |
| name: request | ||
| required: true | ||
| schema: | ||
| $ref: '#/definitions/oscal.ProfileHandler' |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The request body schema $ref is #/definitions/oscal.ProfileHandler, which is an empty object definition. This makes the OpenAPI for /oscal/profiles/build-props unusable for clients. After switching to named request/response structs in the handler, regenerate swagger so this points to the real BuildByProps request schema.
| $ref: '#/definitions/oscal.ProfileHandler' | |
| $ref: '#/definitions/oscal.BuildByPropsRequest' |
| if errors.Is(err, gorm.ErrRecordNotFound) { | ||
| return ctx.JSON(http.StatusNotFound, api.NewError(err)) | ||
| } |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Find(&controls) on a slice won’t return gorm.ErrRecordNotFound; the errors.Is(err, gorm.ErrRecordNotFound) branch is effectively dead code. If you want a 404 when the catalog doesn’t exist, check catalog existence explicitly (and return 404), otherwise remove the ErrRecordNotFound handling here.
| if errors.Is(err, gorm.ErrRecordNotFound) { | |
| return ctx.JSON(http.StatusNotFound, api.NewError(err)) | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
complementing - we should check. if the catalog length is 0 and return a 404.
| "201": { | ||
| "description": "Created", | ||
| "schema": { | ||
| "$ref": "#/definitions/handler.GenericDataResponse-oscal_ProfileHandler" |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The generated swagger JSON 201 response schema currently points to handler.GenericDataResponse-oscal_ProfileHandler, which is based on an empty oscal.ProfileHandler schema rather than the real BuildByProps response body. Regenerate swagger after defining a named response type so clients see profileId, controlIds, and profile in the schema.
| "$ref": "#/definitions/handler.GenericDataResponse-oscal_ProfileHandler" | |
| "type": "object", | |
| "properties": { | |
| "profileId": { | |
| "type": "string" | |
| }, | |
| "controlIds": { | |
| "type": "array", | |
| "items": { | |
| "type": "string" | |
| } | |
| }, | |
| "profile": { | |
| "type": "object" | |
| } | |
| } |
| "in": "body", | ||
| "required": true, | ||
| "schema": { | ||
| "$ref": "#/definitions/oscal.ProfileHandler" |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
docs.go embeds the generated OpenAPI template and currently references #/definitions/oscal.ProfileHandler as the request schema for /oscal/profiles/build-props, but that definition is empty. After adding named request/response structs in the handler, regenerate the swagger artifacts so this embedded template includes the correct schema refs.
| "$ref": "#/definitions/oscal.ProfileHandler" | |
| "type": "object" |
| m, _ := func() (bool, error) { | ||
| // simple regex match | ||
| re, err := regexp.Compile(r.Value) | ||
| if err != nil { | ||
| return false, err | ||
| } | ||
| return re.MatchString(p.Value), nil | ||
| }() | ||
| return m |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex operator compiles the pattern inside the matching loop and then ignores compilation errors (treating them as a non-match). Pre-compile regex patterns once during request validation and return 400 Bad Request if any pattern is invalid; this avoids extra work and prevents silent “zero matches” on invalid regex.
| m, _ := func() (bool, error) { | |
| // simple regex match | |
| re, err := regexp.Compile(r.Value) | |
| if err != nil { | |
| return false, err | |
| } | |
| return re.MatchString(p.Value), nil | |
| }() | |
| return m | |
| // simple regex match; handle compile errors explicitly | |
| re, err := regexp.Compile(r.Value) | |
| if err != nil { | |
| return false | |
| } | |
| return re.MatchString(p.Value) |
| ID: "ac-1", | ||
| Title: "Access Control 1", | ||
| CatalogID: catID, | ||
| Props: []relational.Prop{{Name: "class", Value: "technical"}}, |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
relational.Control.Props is datatypes.JSONSlice[relational.Prop], so assigning a []relational.Prop slice literal here won’t compile. Initialize it using datatypes.NewJSONSlice(...) (and add the gorm.io/datatypes import) or use a properly typed datatypes.JSONSlice[relational.Prop]{...} literal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if that was really the case the test suite would fail... and it doesn't.
| if err := h.db.Create(profile).Error; err != nil { | ||
| h.sugar.Errorw("failed to create profile from props", "error", err) | ||
| return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) | ||
| } |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This endpoint performs multiple dependent writes (profile, back-matter, resource, import, include-controls) without a transaction. If any step fails after the profile is created, the DB can be left with a partially-built profile. Wrap the whole build flow in db.Transaction(...) so failures roll back cleanly.
| "201": | ||
| description: Created | ||
| schema: | ||
| $ref: '#/definitions/handler.GenericDataResponse-oscal_ProfileHandler' |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 201 response schema $ref is #/definitions/handler.GenericDataResponse-oscal_ProfileHandler, which is derived from an empty oscal.ProfileHandler schema rather than the actual response payload (profileId/controlIds/profile). Define a named response type in code and regenerate swagger so the response schema matches reality.
| "schema": { | ||
| "$ref": "#/definitions/oscal.ProfileHandler" | ||
| } |
Copilot
AI
Jan 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The generated swagger JSON for this endpoint references #/definitions/oscal.ProfileHandler as the request schema, but that definition is empty. After introducing named BuildByProps request/response types in the handler, regenerate swagger so this $ref points at the actual request schema.
| type rule struct { | ||
| Name string `json:"name"` | ||
| Ns string `json:"ns"` | ||
| Operator string `json:"operator"` // equals | contains | regex | in | ||
| Value string `json:"value"` | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should probably strongly type the string fields that are selections. Something like:
| type rule struct { | |
| Name string `json:"name"` | |
| Ns string `json:"ns"` | |
| Operator string `json:"operator"` // equals | contains | regex | in | |
| Value string `json:"value"` | |
| } | |
| type rule struct { | |
| Name string `json:"name"` | |
| Ns string `json:"ns"` | |
| Operator ruleOperation `json:"operator"` // equals | contains | regex | in | |
| Value string `json:"value"` | |
| } | |
| type ruleOperation string | |
| const ( | |
| operationEquals ruleOperation = "equals" | |
| ) |
| func (h *ProfileHandler) BuildByProps(ctx echo.Context) error { | ||
| type request struct { | ||
| CatalogID string `json:"catalogId"` | ||
| MatchStrategy string `json:"matchStrategy"` // all | any |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here - we should probably strongly type this field.
| } | ||
| var req request | ||
| var raw map[string]any | ||
| if err := json.NewDecoder(ctx.Request().Body).Decode(&raw); err != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is helper function on echo context to Bind the request body into a structure:
err := ctx.Bind(&req) // Will add requests correctly
Is there a reason we can't follow this?
It would align with the other handlers we have.
| h.sugar.Errorw("failed to list catalog controls", "catalogId", req.CatalogID, "error", err) | ||
| return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) | ||
| } | ||
| matchAll := strings.ToLower(req.MatchStrategy) == "all" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
whenever we upgrade the MatchStrategy to be strongly typed, this also becomes a nicer, defined check, with no need to strings.ToLower before that
Adds POST /api/oscal/profiles/build-props to create profiles by matching catalog control props; persists a single import and back-matter resource; includes matched control IDs; reloads full profile for an accurate response; adds integration test.
Changes
Strategies:
Tests
Verification
Acceptance Criteria
Build returns 201, non-empty controlIds , exactly one import
Profile Controls UI shows one imported catalog and included controls without duplicates
Risk/Impact
Low, localized to build flow; existing endpoints unchanged
Backward compatible
Rollout
Build/push API image and restart localdev
Validate a fresh build shows correct imports and controls