Skip to content

Commit 9ee86ec

Browse files
SyniRonclaude
andauthored
Phase 3 tracer: GET /api/v1/milpacs/ranks end-to-end on the new net/http stack (#150)
* Phase 3 tracer: types foundations, error writer, and the ranks route on the new net/http stack - types/: hand-written wire types (conventions doc'd on the package); RankExpanded/RanksResponse as the worked struct example, RosterType as the worked enum-marshaler example (zero value emits _UNSPECIFIED, unknown values fall back to the number) - rest/: the new stack — PRD middleware chain (sentry → metrics → auth → gzip → mux) with documented extension points for #130/#132, the gRPC-status error-writer choke point with the full frozen code→HTTP table, JSON 404 catch-all, per-route scope gates, and the ranks route - rest tests replay the implemented battery cases against the committed goldens (incl. all auth tiers driven at the ranks route) plus gzip round-trip, chain-order, outage and empty-list behavior Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Phase 3 tracer: spec validation of the new stack's observed ranks responses Replays the implemented battery cases against the new stack and validates the OBSERVED responses against openapi/openapi.yaml with the contract loop's non-vacuousness rules (explicit status, JSON requires a schema); the unknown-path tier is asserted to stay off-spec, and the ranks 401 tier is validated live. Mutation-probed: an extra response field fails on additionalProperties. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Refactor: gateway and gRPC stacks delegate auth/gzip/key-context to the rest package Single source while both stacks are in-tree (PRD #112 dual-stack window): - servers/gateway buildAPIHandler now chains rest.AuthMiddleware and rest.GzipMiddleware; its private copies are deleted - servers/grpc ContextWithKey/KeyFromContext delegate to rest's, so both stacks share one context key and the gateway's Sentry key-id tagging keeps working behind the delegated middleware - the auth middleware unit tests move to rest/auth_test.go with the implementation (plus key-on-context and requireScope coverage); the gateway chain tests keep pinning the production chain end to end - contract harness quiets the rest loggers the delegated middleware logs through (gofmt -l noise in datastores/mysql.go, milpacs/profiles.go, tools.go pre-exists on develop — untouched.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rest: thread request context through the write choke points; log 5xx; fix marshal fallback Review fix-wave items 6-8 (#125): - writeError/writeJSON now take *http.Request so every choke-point log line carries method+path before the fan-out slices (#126-#129) copy the call shape. - Every >=500 response is Error-logged (code, message, method, path) at the choke point - production outages stay visible server-side if the cutover (#134) lands before the Sentry slice (#132). - The marshal-failure fallback no longer uses http.Error (text/plain + nosniff + trailing newline - off-contract three ways): it hand-writes the constant JSON body with application/json + 500, and logs the marshal error together with the original code/message and request context so the real failure cannot vanish. json.Marshal sits behind a package seam (marshalJSON) so the otherwise-unreachable guard is pinned by a test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rest: auth datastore failure is a logged 503, not a silent 401 Review fix-wave items 5 and 11 (#125): - Split the err / key==nil branches in AuthMiddleware. Zero rows keeps the generic plain-text 401 tier (golden-pinned, leaks nothing). A datastore ERROR is a server fault: it is now Error-logged with method+path (never the token) and answered with the Unavailable JSON (503, code 14) through the writeError choke point - a 401 there told legitimate clients their key went bad in the middle of an outage, and the goldens structurally cannot witness the branch. Applies to the legacy gateway too (shared middleware - deliberate: same outage, same wrong 401). - Corrected the context-key consumer list: the new stack's sentry and metrics middlewares are UPSTREAM of auth and never see this value (r.WithContext clones the request); the actual consumers are the per-route scope checks and the legacy gateway's Sentry tagging, which sits inside auth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rest: wrong-method on a known route is 405 + Allow; HEAD and unknown-404 pinned Human ruling on review Critical 1 (#125), the PRD #112 enumerated break: wrong-method on an existing route returns 405 Method Not Allowed with Allow: GET, HEAD (was 501 on the old stack, 404 in the un-pinned new code); HEAD stays a supported read verb (200 on valid routes, no body - net/http suppresses it at the server, observed through a live httptest.Server); 404 stays reserved for genuinely unknown routes. Full rationale recorded in the PRD #112 deliberate-breaks list entry and on the pinning tests. Code mapping (deliberate): no code in the frozen code->HTTP table yields 405, so the body keeps the old stack's wrong-method JSON verbatim - code 12 Unimplemented, "Method Not Allowed" - and only the HTTP status and Allow header change; writeStatusJSON carries the status override. The pins are new-stack-only tests, NOT battery cases: the golden corpus replays against the old stack too, and the old stack answers 501 - a shared case would poison the old-stack suite. The 405 is net-new decided behavior, not recorded-from-old-stack behavior. Mechanism: the "/" catch-all fallback re-asks the mux whether the path matches under GET (the mux's built-in 405 can never fire while the catch-all matches everything). Pattern-based, so the parameterized fan-out routes (#126-#129) are covered with no per-route bookkeeping. Also from the fix wave: - item 10: handle(mux, pattern, scope, handler) registration helper - the scope gate is a required positional argument, not a wrapping convention; ranks route converted; recipe step 3 updated. - item 2: corrected the r.Pattern extension-point doc - AuthMiddleware's r.WithContext clone means the mux sets Pattern on the inner copy only, so the route label must reach the outer metrics layer via the context label-holder mechanism (same as key-id); mechanism documented precisely for #130, holder deliberately not implemented (no consumer yet). - item 4: package doc no longer claims nothing routes production traffic here - auth/gzip middleware already serve production via the gateway delegation (0299988); the handler stack is what stays test-mounted until #134. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rest: spec validation fails on unclassified paths instead of skipping Review fix-wave item 3 (#125): TestNewStack_SpecValidation silently continue'd implemented cases whose path was not classified in specRoutes - 6 of 9 cases skipped and the specRoutes require was unreachable. The skip is gone: every implemented case is validated, an unclassified path is a red test, and the previously-dead guard is the failure message. The two paths the 401-tier cases replay against are now classified (profile-by-id, tickets list - their operations document 401 explicitly), the file header no longer overstates what the suite witnesses, and the add-a-route recipe gains step 6: classify the new case paths in specRoutes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rest: pin gzipped error responses (gzip-outside-mux chain order) Review fix-wave item 9 (#125): a wrong-scope 403 with Accept-Encoding: gzip must come back compressed and decompress to the frozen PermissionDenied JSON. The error path is written inside the mux (requireScope -> writeError), so this pins the half of the chain-order criterion the existing tests missed - the 200 round-trip only witnesses the handler path, and the 401 test only witnesses auth-outside-gzip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * types: two wording fixes in the RosterType doc comments From the review suggestion rollup (#125): rosterTypeNames is a map, not a slice - "Index == enum value" implied the wrong shape; and "retired proto enum" overstated the timeline - the proto enum retires at Phase 4, it is still in-tree today. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * rest: pin wrong-method without creds as the 401 auth tier Auth runs before routing, so unauthenticated wrong-method requests get the scheme-tier 401 with no Allow header — the 405 surface exists only past auth. Closes the one coverage gap the targeted re-review recorded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5c46f9e commit 9ee86ec

20 files changed

Lines changed: 1694 additions & 190 deletions

contract/harness_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/7cav/api/datastores"
1313
"github.com/7cav/api/proto"
14+
"github.com/7cav/api/rest"
1415
"github.com/7cav/api/servers/gateway"
1516
grpcServices "github.com/7cav/api/servers/grpc"
1617
"google.golang.org/grpc"
@@ -139,6 +140,7 @@ func quietProductionLoggers() {
139140
gateway.Info, gateway.Warn,
140141
grpcServices.Info, grpcServices.Warn,
141142
datastores.Info, datastores.Warn,
143+
rest.Info, rest.Warn, // the gateway delegates auth/gzip to rest (#125)
142144
} {
143145
l.SetOutput(io.Discard)
144146
}

rest/auth.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package rest
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
"github.com/7cav/api/datastores"
8+
)
9+
10+
// The HTTP auth middleware below moved here verbatim from servers/gateway
11+
// (its original seam) for the Phase 3 rewrite: the gateway delegates to this
12+
// implementation until it is deleted, so the two stacks can never diverge.
13+
// The two-tier 401 behavior is golden-pinned (#106): scheme errors name the
14+
// expected header form, unknown keys get the generic line, both plain text,
15+
// no WWW-Authenticate challenge.
16+
17+
// maxTokenLen is the maximum length of a raw API key we'll accept.
18+
// cav7_ prefix (5) + 64 hex chars = 69; 128 gives generous headroom.
19+
const maxTokenLen = 128
20+
21+
// errBearerScheme is the 401 body returned when the Authorization header is
22+
// missing or doesn't carry a usable Bearer token (no/empty/oversized token).
23+
// It names the expected format so callers who paste a raw key without the
24+
// "Bearer " prefix get a self-explanatory error. The key-validation-failure
25+
// branch stays the generic "Unauthorized" so it leaks nothing about whether a
26+
// key exists, is expired, or lacks scopes.
27+
const errBearerScheme = "Unauthorized: expected 'Authorization: Bearer <key>' header"
28+
29+
// AuthMiddleware authenticates every request against the upstream key tables
30+
// (ADR 0004) and attaches the validated key to the request context. It runs
31+
// BEFORE routing — an unknown path without credentials is a 401, not a 404
32+
// (golden-pinned). Authorization (scope membership) is per-route: see
33+
// requireScope.
34+
//
35+
// Exported because the legacy gateway chain reuses it until cutover deletes
36+
// that stack.
37+
func AuthMiddleware(ds datastores.Datastore, next http.Handler) http.Handler {
38+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+
token := datastores.ParseBearerToken(r.Header.Get("Authorization"), maxTokenLen)
40+
if token == "" {
41+
Warn.Printf("Unauthorized HTTP access attempt (bad bearer scheme) from %s", r.RemoteAddr)
42+
http.Error(w, errBearerScheme, http.StatusUnauthorized)
43+
return
44+
}
45+
46+
key, err := ds.ValidateApiKey(token)
47+
if err != nil {
48+
// Datastore failure is a server fault, not a credential
49+
// rejection: a 401 here would tell a legitimate client its key
50+
// went bad in the middle of an outage. Unavailable (503) signals
51+
// retryable, through the JSON choke point like every non-401
52+
// error. Log the cause with request context — the goldens cannot
53+
// witness this branch — but NEVER the token.
54+
Error.Printf("validating API key for %s %s: %v", r.Method, r.URL.Path, err)
55+
writeError(w, r, codeUnavailable, "service unavailable")
56+
return
57+
}
58+
if key == nil {
59+
// Zero rows: an unknown/expired key. The generic 401 tier —
60+
// leaks nothing about whether a key exists, is expired, or
61+
// lacks scopes (golden-pinned, #106).
62+
Warn.Printf("Unauthorized HTTP access attempt from %s", r.RemoteAddr)
63+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
64+
return
65+
}
66+
67+
// Attach the validated key to the request ctx so INNER consumers can
68+
// identify the caller without ever seeing the bearer token: the
69+
// per-route scope checks (requireScope) and, until cutover deletes
70+
// it, the legacy gateway's Sentry key-id tagging (its sentry layer
71+
// sits inside auth). The new stack's sentry/metrics middlewares are
72+
// UPSTREAM (outer) of auth — r.WithContext clones the request, so
73+
// their request never carries this value; key-id reaches them via
74+
// the context label-holder mechanism (#130), not this key.
75+
next.ServeHTTP(w, r.WithContext(ContextWithKey(r.Context(), key)))
76+
})
77+
}
78+
79+
// requireScope gates a route on scope membership (ADR 0004: scope checks are
80+
// per-handler; "read" does not imply "read:tickets" and vice versa). A key
81+
// lacking the scope gets the PermissionDenied JSON via the error-writer
82+
// choke point — message string frozen by the goldens.
83+
func requireScope(scope string, next http.Handler) http.Handler {
84+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
85+
if !KeyFromContext(r.Context()).HasScope(scope) {
86+
writeError(w, r, codePermissionDenied, "scope required: %s", scope)
87+
return
88+
}
89+
next.ServeHTTP(w, r)
90+
})
91+
}
92+
93+
// keyContextKey is the private type used to attach *ApiKeyResult to a request
94+
// ctx. Private so external packages can't accidentally clobber it.
95+
type keyContextKey struct{}
96+
97+
// ContextWithKey returns a new context carrying the given API key result.
98+
func ContextWithKey(ctx context.Context, key *datastores.ApiKeyResult) context.Context {
99+
return context.WithValue(ctx, keyContextKey{}, key)
100+
}
101+
102+
// KeyFromContext returns the *ApiKeyResult attached to ctx, or nil if none.
103+
func KeyFromContext(ctx context.Context) *datastores.ApiKeyResult {
104+
v, _ := ctx.Value(keyContextKey{}).(*datastores.ApiKeyResult)
105+
return v
106+
}

rest/auth_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package rest
2+
3+
// The auth middleware tests moved here with the middleware itself (from
4+
// servers/gateway): same two-tier 401 behavior, now pinned at its single
5+
// source. The golden corpus pins the same behavior end-to-end on both
6+
// stacks.
7+
8+
import (
9+
"io"
10+
"net/http"
11+
"net/http/httptest"
12+
"strings"
13+
"testing"
14+
15+
"github.com/7cav/api/datastores"
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
// fakeAuthDatastore embeds the Datastore interface so it satisfies the type
21+
// without implementing every method; only ValidateApiKey is exercised by
22+
// AuthMiddleware. Any other call panics (nil method) — a loud failure if a
23+
// test accidentally reaches further into the datastore.
24+
type fakeAuthDatastore struct {
25+
datastores.Datastore
26+
validateApiKey func(string) (*datastores.ApiKeyResult, error)
27+
}
28+
29+
func (f *fakeAuthDatastore) ValidateApiKey(rawKey string) (*datastores.ApiKeyResult, error) {
30+
return f.validateApiKey(rawKey)
31+
}
32+
33+
// callMiddleware runs AuthMiddleware in front of a handler that records
34+
// whether it was reached, and returns the recorded response plus the
35+
// next-called flag and the key the handler saw on its context.
36+
func callMiddleware(t *testing.T, ds datastores.Datastore, authHeader string) (*httptest.ResponseRecorder, bool, *datastores.ApiKeyResult) {
37+
t.Helper()
38+
nextCalled := false
39+
var seenKey *datastores.ApiKeyResult
40+
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41+
nextCalled = true
42+
seenKey = KeyFromContext(r.Context())
43+
w.WriteHeader(http.StatusOK)
44+
})
45+
h := AuthMiddleware(ds, next)
46+
47+
req := httptest.NewRequest(http.MethodGet, "/api/v1/whatever", nil)
48+
if authHeader != "" {
49+
req.Header.Set("Authorization", authHeader)
50+
}
51+
rr := httptest.NewRecorder()
52+
h.ServeHTTP(rr, req)
53+
return rr, nextCalled, seenKey
54+
}
55+
56+
func TestAuthMiddleware_NoAuthHeader_NamesBearerScheme(t *testing.T) {
57+
ds := &fakeAuthDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) {
58+
t.Fatal("ValidateApiKey must not be called when no bearer token is present")
59+
return nil, nil
60+
}}
61+
rr, nextCalled, _ := callMiddleware(t, ds, "")
62+
63+
assert.Equal(t, http.StatusUnauthorized, rr.Code)
64+
assert.False(t, nextCalled)
65+
body := rr.Body.String()
66+
assert.Contains(t, body, "Bearer")
67+
assert.Contains(t, body, "Authorization")
68+
}
69+
70+
func TestAuthMiddleware_RawKeyNoBearerPrefix_NamesBearerScheme(t *testing.T) {
71+
ds := &fakeAuthDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) {
72+
t.Fatal("ValidateApiKey must not be called when the Bearer scheme is absent")
73+
return nil, nil
74+
}}
75+
rr, nextCalled, _ := callMiddleware(t, ds, "cav7_rawkeywithoutprefix")
76+
77+
assert.Equal(t, http.StatusUnauthorized, rr.Code)
78+
assert.False(t, nextCalled)
79+
body := rr.Body.String()
80+
assert.Contains(t, body, "Bearer")
81+
assert.Contains(t, body, "Authorization")
82+
}
83+
84+
func TestAuthMiddleware_BadKey_GenericUnauthorizedNoLeak(t *testing.T) {
85+
ds := &fakeAuthDatastore{validateApiKey: func(token string) (*datastores.ApiKeyResult, error) {
86+
assert.Equal(t, "cav7_badkey", token)
87+
return nil, nil // zero rows → nil result, no error
88+
}}
89+
rr, nextCalled, _ := callMiddleware(t, ds, "Bearer cav7_badkey")
90+
91+
assert.Equal(t, http.StatusUnauthorized, rr.Code)
92+
assert.False(t, nextCalled)
93+
body := strings.TrimSpace(rr.Body.String())
94+
// Generic — must NOT name the Bearer scheme (that branch is for scheme errors)
95+
// and must not leak anything about the key.
96+
assert.Equal(t, "Unauthorized", body)
97+
assert.NotContains(t, body, "Bearer")
98+
}
99+
100+
func TestAuthMiddleware_ValidKey_CallsNextWithKeyOnContext(t *testing.T) {
101+
key := &datastores.ApiKeyResult{KeyId: 1, UserId: 2}
102+
ds := &fakeAuthDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) {
103+
return key, nil
104+
}}
105+
rr, nextCalled, seenKey := callMiddleware(t, ds, "Bearer cav7_goodkey")
106+
107+
require.True(t, nextCalled)
108+
assert.Equal(t, http.StatusOK, rr.Code)
109+
assert.Same(t, key, seenKey,
110+
"the validated key must reach the handler via the request context")
111+
}
112+
113+
// A datastore failure during key validation is a server fault, not a
114+
// credential rejection: it must NOT masquerade as the 401 tier (telling a
115+
// legitimate client its key went bad mid-outage), and it must be Error-logged
116+
// with request context — the goldens structurally cannot witness this branch,
117+
// so this test is the only thing keeping the outage visible. The bearer token
118+
// must never reach the log.
119+
func TestAuthMiddleware_DatastoreError_Is503AndLogged(t *testing.T) {
120+
buf := captureErrorLog(t)
121+
ds := &fakeAuthDatastore{validateApiKey: func(string) (*datastores.ApiKeyResult, error) {
122+
return nil, io.ErrUnexpectedEOF
123+
}}
124+
rr, nextCalled, _ := callMiddleware(t, ds, "Bearer cav7_secrettoken")
125+
126+
assert.False(t, nextCalled)
127+
assert.Equal(t, http.StatusServiceUnavailable, rr.Code)
128+
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
129+
assert.JSONEq(t, `{"code":14,"message":"service unavailable","details":[]}`, rr.Body.String(),
130+
"Unavailable JSON via the choke point, leaking nothing about the key")
131+
132+
logged := buf.String()
133+
assert.Contains(t, logged, "unexpected EOF")
134+
assert.Contains(t, logged, "GET")
135+
assert.Contains(t, logged, "/api/v1/whatever")
136+
assert.NotContains(t, logged, "cav7_secrettoken", "the bearer token must NEVER be logged")
137+
}
138+
139+
// requireScope is the per-route authorization gate (ADR 0004: scope checks
140+
// are per-handler; one scope never implies another).
141+
func TestRequireScope(t *testing.T) {
142+
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143+
w.WriteHeader(http.StatusOK)
144+
})
145+
h := requireScope("read", next)
146+
147+
t.Run("key with the scope passes", func(t *testing.T) {
148+
req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
149+
req = req.WithContext(ContextWithKey(req.Context(),
150+
&datastores.ApiKeyResult{Scopes: map[string]struct{}{"read": {}}}))
151+
rr := httptest.NewRecorder()
152+
h.ServeHTTP(rr, req)
153+
assert.Equal(t, http.StatusOK, rr.Code)
154+
})
155+
156+
t.Run("key with a different scope is denied", func(t *testing.T) {
157+
req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
158+
req = req.WithContext(ContextWithKey(req.Context(),
159+
&datastores.ApiKeyResult{Scopes: map[string]struct{}{"read:tickets": {}}}))
160+
rr := httptest.NewRecorder()
161+
h.ServeHTTP(rr, req)
162+
assert.Equal(t, http.StatusForbidden, rr.Code)
163+
assert.JSONEq(t, `{"code":7,"message":"scope required: read","details":[]}`, rr.Body.String())
164+
})
165+
166+
t.Run("no key on context is denied, not a panic", func(t *testing.T) {
167+
rr := httptest.NewRecorder()
168+
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/v1/x", nil))
169+
assert.Equal(t, http.StatusForbidden, rr.Code)
170+
})
171+
}

rest/gzip.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package rest
2+
3+
import (
4+
"compress/gzip"
5+
"net/http"
6+
"strings"
7+
)
8+
9+
// GzipMiddleware moved here verbatim from servers/gateway (compression
10+
// middleware) for the Phase 3 rewrite; the gateway delegates to it until
11+
// cutover deletes that stack. It sits inside auth (401s are never gzipped)
12+
// and outside the mux, so every routed response — including the JSON 404 —
13+
// compresses when the client asks.
14+
//
15+
// Exported because the legacy gateway chain reuses it until cutover deletes
16+
// that stack.
17+
func GzipMiddleware(next http.Handler) http.Handler {
18+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
20+
w.Header().Set("Content-Encoding", "gzip")
21+
gz := gzip.NewWriter(w)
22+
defer func() {
23+
if err := gz.Close(); err != nil {
24+
// A Close failure means the gzip trailer never reached the
25+
// client — a corrupt body behind an already-written status,
26+
// invisible to the sentry layer outside this one.
27+
Error.Printf("gzip close failed for %s %s (response likely truncated): %v", r.Method, r.URL.Path, err)
28+
}
29+
}()
30+
gzw := &gzipResponseWriter{ResponseWriter: w, Writer: gz}
31+
next.ServeHTTP(gzw, r)
32+
return
33+
}
34+
next.ServeHTTP(w, r)
35+
})
36+
}
37+
38+
type gzipResponseWriter struct {
39+
http.ResponseWriter
40+
Writer *gzip.Writer
41+
}
42+
43+
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
44+
w.Header().Del("Content-Length") // This is necessary as otherwise it will have the uncompressed length
45+
return w.Writer.Write(b)
46+
}

rest/milpacs.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package rest
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/7cav/api/datastores"
7+
"github.com/7cav/api/proto"
8+
"github.com/7cav/api/types"
9+
)
10+
11+
// getAllRanks serves GET /api/v1/milpacs/ranks: the rank reference list,
12+
// golden-pinned by milpacs/ranks. Error message string frozen from the old
13+
// handler (servers/grpc GetAllRanks).
14+
func getAllRanks(ds datastores.Datastore) http.Handler {
15+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
ranks, err := ds.FindAllRanks()
17+
if err != nil {
18+
writeError(w, r, codeInternal, "error fetching ranks: %v", err)
19+
return
20+
}
21+
writeJSON(w, r, types.RanksResponse{Ranks: ranksFromProto(ranks)})
22+
})
23+
}
24+
25+
// ranksFromProto maps the datastore's proto-typed rows to the wire types.
26+
// The mapping layer disappears at cutover (#134) when the Datastore
27+
// interface itself moves to the types package; until then each handler owns
28+
// its map — and the allocation discipline: empty collections are allocated
29+
// ([] on the wire), never nil.
30+
func ranksFromProto(in []*proto.RankExpanded) []*types.RankExpanded {
31+
out := make([]*types.RankExpanded, 0, len(in))
32+
for _, r := range in {
33+
out = append(out, &types.RankExpanded{
34+
RankShort: r.GetRankShort(),
35+
RankFull: r.GetRankFull(),
36+
RankImageUrl: r.GetRankImageUrl(),
37+
RankId: r.GetRankId(),
38+
RankDisplayOrder: r.GetRankDisplayOrder(),
39+
})
40+
}
41+
return out
42+
}

0 commit comments

Comments
 (0)