Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .spectral.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,21 @@ rules:
functionOptions:
schema:
type: object

aep-132-list-results-field:
description: List response array field must be named 'results' per AEP-132
message: "List response schema must use 'results' as the array field name"
severity: warn
given: $.components.schemas[?(@.properties.next_page_token)].properties
then:
field: results
function: truthy

aep-133-create-location-header:
description: Create (201) response must include Location header per AEP-133
message: "Create operation 201 response must include a Location header"
severity: warn
given: $.paths[*].post.responses.201
then:
field: headers.Location
function: truthy
4 changes: 2 additions & 2 deletions api/v1alpha1/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -485,9 +485,9 @@ components:

Implements AEP-132 List standard method requirements.
required:
- policies
- results
properties:
policies:
results:
type: array
description: List of policy resources matching the request criteria
items:
Expand Down
4 changes: 2 additions & 2 deletions api/v1alpha1/types.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions internal/api/server/server.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions internal/apiserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/dcm-project/policy-manager/internal/api/server"
"github.com/dcm-project/policy-manager/internal/config"
"github.com/dcm-project/policy-manager/internal/logging"
custommiddleware "github.com/dcm-project/policy-manager/internal/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
Expand Down Expand Up @@ -43,6 +44,7 @@ func (s *Server) Run(ctx context.Context) error {
router.Use(logging.RequestLogger)
router.Use(middleware.Logger)
router.Use(middleware.Recoverer)
router.Use(custommiddleware.ProblemJSON)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): The ProblemJSON middleware order likely prevents it from rewriting responses generated by Recoverer (e.g., panics).

Because of Chi’s middleware wrapping order, adding ProblemJSON after middleware.Recoverer means panics are handled by Recoverer using the original ResponseWriter, so ProblemJSON never sees or rewrites those 500 responses. That leaves panic/500 responses without the application/problem+json Content-Type, which may violate AEP-193. To cover all errors, including those from Recoverer, register ProblemJSON before Recoverer so it wraps the full stack.


swagger, err := v1alpha1.GetSwagger()
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/engineserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
engineserver "github.com/dcm-project/policy-manager/internal/api/engine"
"github.com/dcm-project/policy-manager/internal/config"
"github.com/dcm-project/policy-manager/internal/logging"
custommiddleware "github.com/dcm-project/policy-manager/internal/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
Expand Down Expand Up @@ -43,6 +44,7 @@ func (s *Server) Run(ctx context.Context) error {
router.Use(logging.RequestLogger)
router.Use(middleware.Logger)
router.Use(middleware.Recoverer)
router.Use(custommiddleware.ProblemJSON)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Same middleware ordering concern here: ProblemJSON may not affect error responses generated by Recoverer.

To match the API server and ensure middleware.Recoverer errors are returned as application/problem+json, register ProblemJSON before middleware.Recoverer in this stack.


swagger, err := engineserverapi.GetSwagger()
if err != nil {
Expand Down
6 changes: 3 additions & 3 deletions internal/handlers/v1alpha1/converters.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,12 @@ func policyV1Alpha1ToServer(p v1alpha1.Policy) server.Policy {
}

func listResponseV1Alpha1ToServer(r v1alpha1.PolicyList) server.PolicyList {
policies := make([]server.Policy, len(r.Policies))
for i, p := range r.Policies {
policies := make([]server.Policy, len(r.Results))
for i, p := range r.Results {
policies[i] = policyV1Alpha1ToServer(p)
}
return server.PolicyList{
NextPageToken: r.NextPageToken,
Policies: policies,
Results: policies,
}
}
7 changes: 5 additions & 2 deletions internal/handlers/v1alpha1/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package v1alpha1

import (
"context"
"fmt"

"github.com/dcm-project/policy-manager/api/v1alpha1"
"github.com/dcm-project/policy-manager/internal/api/server"
Expand Down Expand Up @@ -62,9 +63,11 @@ func (h *PolicyHandler) CreatePolicy(ctx context.Context, request server.CreateP
}

log.Info("Policy created", "policy_id", *created.Id)
// Convert back to server.Policy
return server.CreatePolicy201JSONResponse{
Body: policyV1Alpha1ToServer(*created),
Headers: server.CreatePolicy201ResponseHeaders{
Location: fmt.Sprintf("/api/v1alpha1/policies/%s", *created.Id),
Comment on lines +68 to +69
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Consider URL-escaping the policy ID before embedding it into the Location header path.

If created.Id can include characters that aren’t path-safe (spaces, #, /, etc.), the resulting Location header may be an invalid or ambiguous URI. Please use url.PathEscape(*created.Id) (or equivalent) when constructing the path, and consider defining the path template as a shared constant to keep it consistent across handlers.

},
}, nil
}

Expand Down Expand Up @@ -107,7 +110,7 @@ func (h *PolicyHandler) ListPolicies(ctx context.Context, request server.ListPol
return h.handleListPoliciesError(err, request), nil
}

log.Debug("ListPolicies completed", "count", len(result.Policies))
log.Debug("ListPolicies completed", "count", len(result.Results))
return server.ListPolicies200JSONResponse(listResponseV1Alpha1ToServer(*result)), nil
}

Expand Down
13 changes: 7 additions & 6 deletions internal/handlers/v1alpha1/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ var _ = Describe("PolicyHandler", func() {
createResponse, ok := response.(server.CreatePolicy201JSONResponse)
Expect(ok).To(BeTrue(), "response should be CreatePolicy201JSONResponse")
Expect(*createResponse.Body.Id).To(Equal("test-policy"))
Expect(createResponse.Headers.Location).To(Equal("/api/v1alpha1/policies/test-policy"))
})

It("should return 400 when body is nil", func() {
Expand Down Expand Up @@ -227,7 +228,7 @@ var _ = Describe("PolicyHandler", func() {
pt2 := v1alpha1.USER
mockService.ListPoliciesFn = func(_ context.Context, _ *string, _ *string, _ *string, _ *int32) (*v1alpha1.PolicyList, error) {
return &v1alpha1.PolicyList{
Policies: []v1alpha1.Policy{
Results: []v1alpha1.Policy{
{
Id: &policyID1,
Path: &path1,
Expand All @@ -254,9 +255,9 @@ var _ = Describe("PolicyHandler", func() {
Expect(err).NotTo(HaveOccurred())
listResponse, ok := response.(server.ListPolicies200JSONResponse)
Expect(ok).To(BeTrue(), "response should be ListPolicies200JSONResponse")
Expect(listResponse.Policies).To(HaveLen(2))
Expect(*listResponse.Policies[0].Id).To(Equal("policy-1"))
Expect(*listResponse.Policies[1].Id).To(Equal("policy-2"))
Expect(listResponse.Results).To(HaveLen(2))
Expect(*listResponse.Results[0].Id).To(Equal("policy-1"))
Expect(*listResponse.Results[1].Id).To(Equal("policy-2"))
})

It("should pass filter parameter to service", func() {
Expand All @@ -267,7 +268,7 @@ var _ = Describe("PolicyHandler", func() {
mockService.ListPoliciesFn = func(_ context.Context, filter *string, _ *string, _ *string, _ *int32) (*v1alpha1.PolicyList, error) {
receivedFilter = filter
return &v1alpha1.PolicyList{
Policies: []v1alpha1.Policy{},
Results: []v1alpha1.Policy{},
}, nil
}

Expand All @@ -293,7 +294,7 @@ var _ = Describe("PolicyHandler", func() {
receivedPageToken = pageToken
receivedPageSize = pageSize
return &v1alpha1.PolicyList{
Policies: []v1alpha1.Policy{},
Results: []v1alpha1.Policy{},
}, nil
}

Expand Down
42 changes: 42 additions & 0 deletions internal/middleware/problemjson.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package middleware

Check failure on line 1 in internal/middleware/problemjson.go

View workflow job for this annotation

GitHub Actions / lint / lint

ST1000: at least one file in a package should have a package comment (staticcheck)

import (
"net/http"
"strings"
)

// ProblemJSON is a Chi middleware that rewrites the Content-Type header
// from application/json to application/problem+json for error responses
// (status >= 400), per AEP-193 and RFC 9457.
func ProblemJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(&problemJSONWriter{ResponseWriter: w}, r)
})
}

type problemJSONWriter struct {
http.ResponseWriter
wroteHeader bool
}

func (w *problemJSONWriter) WriteHeader(code int) {
if !w.wroteHeader && code >= 400 {
ct := w.Header().Get("Content-Type")
if strings.HasPrefix(ct, "application/json") {
w.Header().Set("Content-Type", strings.Replace(ct, "application/json", "application/problem+json", 1))
Comment on lines +17 to +26
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): The ResponseWriter wrapper does not forward optional interfaces (Flusher, Hijacker, Pusher, etc.), which may break callers that rely on them.

Since this wrapper only embeds http.ResponseWriter and doesn’t forward optional interfaces (Flusher, Hijacker, Pusher, CloseNotifier), any middleware/handler doing type assertions for streaming, websockets, etc. may break. Please add conditional delegation for these interfaces in problemJSONWriter or otherwise preserve the original concrete type so existing behavior remains unchanged.

}
}
w.wroteHeader = true
w.ResponseWriter.WriteHeader(code)
}

func (w *problemJSONWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}

func (w *problemJSONWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
60 changes: 60 additions & 0 deletions internal/middleware/problemjson_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package middleware

import (
"net/http"
"net/http/httptest"
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestMiddleware(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Middleware Suite")
}

var _ = Describe("ProblemJSON", func() {
makeHandler := func(status int, contentType string) http.Handler {
return ProblemJSON(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", contentType)
w.WriteHeader(status)
w.Write([]byte(`{"type":"INTERNAL","status":500,"title":"error"}`))

Check failure on line 22 in internal/middleware/problemjson_test.go

View workflow job for this annotation

GitHub Actions / lint / lint

Error return value of `w.Write` is not checked (errcheck)
}))
}

It("should rewrite Content-Type for 400 responses", func() {
rec := httptest.NewRecorder()
makeHandler(400, "application/json").ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))

Check failure on line 28 in internal/middleware/problemjson_test.go

View workflow job for this annotation

GitHub Actions / lint / lint

"GET" can be replaced by http.MethodGet (usestdlibvars)
Expect(rec.Header().Get("Content-Type")).To(Equal("application/problem+json"))
Comment on lines +26 to +29
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Add an E2E test to verify error responses use application/problem+json

Since the implementation plan also called out an end-to-end check, please add an E2E test (e.g., triggering a known 4xx/5xx route) to confirm the middleware is wired into both API servers and that the Content-Type header is preserved as application/problem+json in real requests, not only in unit-level middleware tests.

Suggested implementation:

	It("should rewrite Content-Type for 400 responses", func() {
		rec := httptest.NewRecorder()
		makeHandler(400, "application/json").ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
		Expect(rec.Header().Get("Content-Type")).To(Equal("application/problem+json"))
	})

	It("should preserve application/problem+json Content-Type in an end-to-end HTTP request", func() {
		// Arrange: wrap a real HTTP handler with the ProblemJSON middleware and serve via httptest.Server
		handler := ProblemJSON(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Simulate a handler that returns a problem+json 400 response
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusBadRequest)
			_, _ = w.Write([]byte(`{"type":"INTERNAL","status":400,"title":"bad request"}`))
		}))

		server := httptest.NewServer(handler)
		defer server.Close()

		// Act: perform a real HTTP request against the server
		resp, err := http.Get(server.URL)
		Expect(err).NotTo(HaveOccurred())
		defer resp.Body.Close()

		// Assert: middleware has rewritten the Content-Type header on the actual HTTP response
		Expect(resp.StatusCode).To(Equal(http.StatusBadRequest))
		Expect(resp.Header.Get("Content-Type")).To(Equal("application/problem+json"))
	})

If your actual API servers are constructed via helper functions (e.g., NewPublicServer, NewAdminServer) that already wire in the ProblemJSON middleware, you may want to further adapt the E2E test to:

  1. Start each real server instance (public/admin) using those constructors instead of directly wrapping ProblemJSON around a bare handler.
  2. Hit a known 4xx/5xx route on each server and assert the Content-Type is application/problem+json as done above.
  3. Place such tests either here or in a higher-level integration test file, depending on your existing test structure.

})

It("should rewrite Content-Type for 500 responses", func() {
rec := httptest.NewRecorder()
makeHandler(500, "application/json").ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))

Check failure on line 34 in internal/middleware/problemjson_test.go

View workflow job for this annotation

GitHub Actions / lint / lint

"GET" can be replaced by http.MethodGet (usestdlibvars)
Expect(rec.Header().Get("Content-Type")).To(Equal("application/problem+json"))
})

It("should preserve Content-Type for 200 responses", func() {
rec := httptest.NewRecorder()
handler := ProblemJSON(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)

Check failure on line 42 in internal/middleware/problemjson_test.go

View workflow job for this annotation

GitHub Actions / lint / lint

"200" can be replaced by http.StatusOK (usestdlibvars)
w.Write([]byte(`{"status":"ok"}`))

Check failure on line 43 in internal/middleware/problemjson_test.go

View workflow job for this annotation

GitHub Actions / lint / lint

Error return value of `w.Write` is not checked (errcheck)
}))
handler.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))

Check failure on line 45 in internal/middleware/problemjson_test.go

View workflow job for this annotation

GitHub Actions / lint / lint

"GET" can be replaced by http.MethodGet (usestdlibvars)
Expect(rec.Header().Get("Content-Type")).To(Equal("application/json"))
})

It("should preserve Content-Type with charset for 400 responses", func() {
rec := httptest.NewRecorder()
makeHandler(404, "application/json; charset=utf-8").ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))

Check failure on line 51 in internal/middleware/problemjson_test.go

View workflow job for this annotation

GitHub Actions / lint / lint

"GET" can be replaced by http.MethodGet (usestdlibvars)
Expect(rec.Header().Get("Content-Type")).To(Equal("application/problem+json; charset=utf-8"))
})

It("should not touch non-JSON content types on errors", func() {
rec := httptest.NewRecorder()
makeHandler(500, "text/plain").ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))

Check failure on line 57 in internal/middleware/problemjson_test.go

View workflow job for this annotation

GitHub Actions / lint / lint

"GET" can be replaced by http.MethodGet (usestdlibvars)
Expect(rec.Header().Get("Content-Type")).To(Equal("text/plain"))
})
})
2 changes: 1 addition & 1 deletion internal/service/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ func (s *PolicyServiceImpl) ListPolicies(ctx context.Context, filter *string, or

// Build response
response := &v1alpha1.PolicyList{
Policies: apiPolicies,
Results: apiPolicies,
}

if result.NextPageToken != "" {
Expand Down
Loading
Loading