Skip to content

Commit 757cb27

Browse files
Add channel registry, API & health endpoints
Introduce a new channel package with Channel, RetryConfig and a thread-safe in-memory Registry plus a YAML loader to populate channels at startup. Expand the REST Server to expose channel CRUD endpoints and add /healthz and /readyz probes (readyz uses store Ping when available). Implement Ping on Postgres and SQLite stores to support readiness checks. Add comprehensive tests for the channel registry, YAML loader, REST channel API, and health/readiness behavior. Also add gopkg.in/yaml.v3 dependency.
1 parent eca39d1 commit 757cb27

10 files changed

Lines changed: 959 additions & 24 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ require (
1818
golang.org/x/sync v0.20.0 // indirect
1919
golang.org/x/sys v0.42.0 // indirect
2020
golang.org/x/tools v0.42.0 // indirect
21+
gopkg.in/yaml.v3 v3.0.1 // indirect
2122
modernc.org/fileutil v1.4.0 // indirect
2223
modernc.org/libc v1.61.13 // indirect
2324
modernc.org/mathutil v1.7.1 // indirect

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
2323
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
2424
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
2525
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
26+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
27+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
28+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
2629
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
2730
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
2831
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=

internal/api/rest/server.go

Lines changed: 144 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,29 @@
44
//
55
// GET /api/v1/messages — list messages (query: status, limit)
66
// GET /api/v1/messages/{id} — get a single message by ID
7+
// GET /api/v1/channels — list channels
8+
// POST /api/v1/channels — create channel
9+
// GET /api/v1/channels/{id} — get channel by ID
10+
// PUT /api/v1/channels/{id} — update channel
11+
// DELETE /api/v1/channels/{id} — delete channel
12+
// GET /healthz — liveness probe
13+
// GET /readyz — readiness probe (checks store)
714
//
815
// All responses are JSON. Error bodies have the shape {"error":"reason"}.
916
// The server uses Go 1.22 enhanced ServeMux patterns, so unknown methods
1017
// receive an automatic 405 and missing routes receive 404.
1118
package rest
1219

1320
import (
21+
"context"
1422
"database/sql"
1523
"encoding/json"
1624
"errors"
1725
"net/http"
1826
"strconv"
1927
"time"
2028

29+
"github.com/vagnercazarotto/verifhir-gateway/internal/channel"
2130
"github.com/vagnercazarotto/verifhir-gateway/internal/store"
2231
)
2332

@@ -43,18 +52,30 @@ type MessageResponse struct {
4352
UpdatedAt time.Time `json:"updated_at"`
4453
}
4554

46-
// Server is an http.Handler that exposes the message store as a REST API.
55+
// Server is an http.Handler that exposes the message store and channel
56+
// registry as a REST API.
4757
type Server struct {
48-
store store.Store
49-
mux *http.ServeMux
58+
store store.Store
59+
channels *channel.Registry
60+
mux *http.ServeMux
5061
}
5162

52-
// New creates a Server backed by st and registers all routes.
53-
func New(st store.Store) *Server {
54-
s := &Server{store: st}
63+
// New creates a Server backed by st and reg, and registers all routes.
64+
func New(st store.Store, reg *channel.Registry) *Server {
65+
s := &Server{store: st, channels: reg}
5566
s.mux = http.NewServeMux()
67+
// message routes
5668
s.mux.HandleFunc("GET /api/v1/messages", s.listMessages)
5769
s.mux.HandleFunc("GET /api/v1/messages/{id}", s.getMessage)
70+
// channel routes
71+
s.mux.HandleFunc("GET /api/v1/channels", s.listChannels)
72+
s.mux.HandleFunc("POST /api/v1/channels", s.createChannel)
73+
s.mux.HandleFunc("GET /api/v1/channels/{id}", s.getChannel)
74+
s.mux.HandleFunc("PUT /api/v1/channels/{id}", s.updateChannel)
75+
s.mux.HandleFunc("DELETE /api/v1/channels/{id}", s.deleteChannel)
76+
// health routes
77+
s.mux.HandleFunc("GET /healthz", s.healthz)
78+
s.mux.HandleFunc("GET /readyz", s.readyz)
5879
return s
5980
}
6081

@@ -137,6 +158,94 @@ func recordToResponse(rec *store.Record) MessageResponse {
137158
}
138159
}
139160

161+
// ---- channel handlers -----------------------------------------------------
162+
163+
// listChannels handles GET /api/v1/channels
164+
func (s *Server) listChannels(w http.ResponseWriter, r *http.Request) {
165+
writeJSON(w, http.StatusOK, s.channels.List())
166+
}
167+
168+
// createChannel handles POST /api/v1/channels
169+
func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) {
170+
var ch channel.Channel
171+
if err := json.NewDecoder(r.Body).Decode(&ch); err != nil {
172+
writeError(w, http.StatusBadRequest, "invalid JSON body")
173+
return
174+
}
175+
if ch.ID == "" {
176+
writeError(w, http.StatusBadRequest, "id is required")
177+
return
178+
}
179+
if ch.URL == "" {
180+
writeError(w, http.StatusBadRequest, "url is required")
181+
return
182+
}
183+
if err := s.channels.Add(ch); err != nil {
184+
if errors.Is(err, channel.ErrDuplicateID) {
185+
writeError(w, http.StatusConflict, "channel ID already exists")
186+
return
187+
}
188+
writeError(w, http.StatusInternalServerError, "failed to create channel")
189+
return
190+
}
191+
got, _ := s.channels.Get(ch.ID)
192+
writeJSON(w, http.StatusCreated, got)
193+
}
194+
195+
// getChannel handles GET /api/v1/channels/{id}
196+
func (s *Server) getChannel(w http.ResponseWriter, r *http.Request) {
197+
id := r.PathValue("id")
198+
ch, err := s.channels.Get(id)
199+
if err != nil {
200+
if errors.Is(err, channel.ErrNotFound) {
201+
writeError(w, http.StatusNotFound, "channel not found")
202+
return
203+
}
204+
writeError(w, http.StatusInternalServerError, "failed to retrieve channel")
205+
return
206+
}
207+
writeJSON(w, http.StatusOK, ch)
208+
}
209+
210+
// updateChannel handles PUT /api/v1/channels/{id}
211+
func (s *Server) updateChannel(w http.ResponseWriter, r *http.Request) {
212+
id := r.PathValue("id")
213+
var ch channel.Channel
214+
if err := json.NewDecoder(r.Body).Decode(&ch); err != nil {
215+
writeError(w, http.StatusBadRequest, "invalid JSON body")
216+
return
217+
}
218+
ch.ID = id // path wins over body
219+
if ch.URL == "" {
220+
writeError(w, http.StatusBadRequest, "url is required")
221+
return
222+
}
223+
if err := s.channels.Update(ch); err != nil {
224+
if errors.Is(err, channel.ErrNotFound) {
225+
writeError(w, http.StatusNotFound, "channel not found")
226+
return
227+
}
228+
writeError(w, http.StatusInternalServerError, "failed to update channel")
229+
return
230+
}
231+
got, _ := s.channels.Get(id)
232+
writeJSON(w, http.StatusOK, got)
233+
}
234+
235+
// deleteChannel handles DELETE /api/v1/channels/{id}
236+
func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request) {
237+
id := r.PathValue("id")
238+
if err := s.channels.Delete(id); err != nil {
239+
if errors.Is(err, channel.ErrNotFound) {
240+
writeError(w, http.StatusNotFound, "channel not found")
241+
return
242+
}
243+
writeError(w, http.StatusInternalServerError, "failed to delete channel")
244+
return
245+
}
246+
w.WriteHeader(http.StatusNoContent)
247+
}
248+
140249
func writeJSON(w http.ResponseWriter, status int, v any) {
141250
w.Header().Set("Content-Type", "application/json")
142251
w.WriteHeader(status)
@@ -146,3 +255,32 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
146255
func writeError(w http.ResponseWriter, status int, msg string) {
147256
writeJSON(w, status, map[string]string{"error": msg})
148257
}
258+
259+
// pinger is an optional interface that store backends may implement.
260+
// If the backing store satisfies pinger, readyz will call Ping to verify
261+
// database connectivity.
262+
type pinger interface {
263+
Ping(ctx context.Context) error
264+
}
265+
266+
// healthz handles GET /healthz — liveness probe.
267+
// Always returns 200 {"status":"ok"} while the process is running.
268+
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
269+
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
270+
}
271+
272+
// readyz handles GET /readyz — readiness probe.
273+
// Returns 200 {"status":"ok"} when the store is reachable,
274+
// or 503 {"status":"unavailable","reason":"..."} otherwise.
275+
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
276+
if p, ok := s.store.(pinger); ok {
277+
if err := p.Ping(r.Context()); err != nil {
278+
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
279+
"status": "unavailable",
280+
"reason": err.Error(),
281+
})
282+
return
283+
}
284+
}
285+
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
286+
}

0 commit comments

Comments
 (0)