Skip to content

Commit 6c0664d

Browse files
q-uintiwarapter
andcommitted
feat: ensure all handlers explicitly set HTTP status codes
Wrap ResponseWriter in ServeHTTP with a statusResponseWriter that guarantees WriteHeader is always called explicitly. This allows observability middleware to reliably capture status codes, which was not possible when handlers relied on net/http's implicit 200 on first Write call. The wrapper also guards against duplicate WriteHeader calls by ignoring subsequent invocations after the first. Closes #183 Co-Authored-By: iwarapter <iwarapter@users.noreply.github.com>
1 parent 5c3c415 commit 6c0664d

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

handlers_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,101 @@ func newTestResourceHandler() ResourceHandler {
992992
}
993993
}
994994

995+
// statusRecordingResponseWriter wraps an http.ResponseWriter and records
996+
// whether WriteHeader was called explicitly, simulating logging middleware.
997+
type statusRecordingResponseWriter struct {
998+
http.ResponseWriter
999+
calledWriteHeader bool
1000+
status int
1001+
}
1002+
1003+
func (w *statusRecordingResponseWriter) WriteHeader(status int) {
1004+
w.calledWriteHeader = true
1005+
w.status = status
1006+
w.ResponseWriter.WriteHeader(status)
1007+
}
1008+
1009+
func TestServerExplicitStatusCodes(t *testing.T) {
1010+
tests := []struct {
1011+
name string
1012+
method string
1013+
target string
1014+
body io.Reader
1015+
expectedStatus int
1016+
}{
1017+
{
1018+
name: "GET resource",
1019+
method: http.MethodGet,
1020+
target: "/Users/0001",
1021+
expectedStatus: http.StatusOK,
1022+
},
1023+
{
1024+
name: "GET resources",
1025+
method: http.MethodGet,
1026+
target: "/Users",
1027+
expectedStatus: http.StatusOK,
1028+
},
1029+
{
1030+
name: "POST resource",
1031+
method: http.MethodPost,
1032+
target: "/Users",
1033+
body: strings.NewReader(`{"userName": "test", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}`),
1034+
expectedStatus: http.StatusCreated,
1035+
},
1036+
{
1037+
name: "PUT resource",
1038+
method: http.MethodPut,
1039+
target: "/Users/0001",
1040+
body: strings.NewReader(`{"userName": "test_replace", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}`),
1041+
expectedStatus: http.StatusOK,
1042+
},
1043+
{
1044+
name: "DELETE resource",
1045+
method: http.MethodDelete,
1046+
target: "/Users/0001",
1047+
expectedStatus: http.StatusNoContent,
1048+
},
1049+
{
1050+
name: "GET ResourceTypes",
1051+
method: http.MethodGet,
1052+
target: "/ResourceTypes",
1053+
expectedStatus: http.StatusOK,
1054+
},
1055+
{
1056+
name: "GET ResourceType",
1057+
method: http.MethodGet,
1058+
target: "/ResourceTypes/User",
1059+
expectedStatus: http.StatusOK,
1060+
},
1061+
{
1062+
name: "GET Schemas",
1063+
method: http.MethodGet,
1064+
target: "/Schemas",
1065+
expectedStatus: http.StatusOK,
1066+
},
1067+
{
1068+
name: "GET ServiceProviderConfig",
1069+
method: http.MethodGet,
1070+
target: "/ServiceProviderConfig",
1071+
expectedStatus: http.StatusOK,
1072+
},
1073+
}
1074+
1075+
for _, tt := range tests {
1076+
t.Run(tt.name, func(t *testing.T) {
1077+
req := httptest.NewRequest(tt.method, tt.target, tt.body)
1078+
rr := httptest.NewRecorder()
1079+
w := &statusRecordingResponseWriter{ResponseWriter: rr}
1080+
newTestServer(t).ServeHTTP(w, req)
1081+
1082+
if !w.calledWriteHeader {
1083+
t.Error("handler did not explicitly call WriteHeader")
1084+
}
1085+
assertEqualStatusCode(t, tt.expectedStatus, w.status)
1086+
})
1087+
}
1088+
}
1089+
9951090
func newTestServer(t *testing.T) Server {
9961091
userSchema := getUserSchema()
9971092
userSchemaExtension := getUserExtensionSchema()

server.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,33 @@ func NewServer(args *ServerArgs, opts ...ServerOption) (Server, error) {
8686
return *s, nil
8787
}
8888

89+
// statusResponseWriter wraps http.ResponseWriter to ensure WriteHeader is
90+
// always called explicitly. If Write is called without a prior WriteHeader,
91+
// it defaults to http.StatusOK. Subsequent WriteHeader calls are ignored.
92+
// This allows observability middleware to reliably capture status codes.
93+
type statusResponseWriter struct {
94+
http.ResponseWriter
95+
wroteHeader bool
96+
}
97+
98+
func (w *statusResponseWriter) WriteHeader(status int) {
99+
if !w.wroteHeader {
100+
w.wroteHeader = true
101+
w.ResponseWriter.WriteHeader(status)
102+
}
103+
}
104+
105+
func (w *statusResponseWriter) Write(b []byte) (int, error) {
106+
if !w.wroteHeader {
107+
w.WriteHeader(http.StatusOK)
108+
}
109+
return w.ResponseWriter.Write(b)
110+
}
111+
89112
// ServeHTTP dispatches the request to the handler whose pattern most closely matches the request URL.
90113
func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
91114
w.Header().Set("Content-Type", "application/scim+json")
115+
w = &statusResponseWriter{ResponseWriter: w}
92116

93117
path := strings.TrimPrefix(r.URL.Path, "/v2")
94118

0 commit comments

Comments
 (0)