detached 6913815

refactor: centralize HTTP DTO responses for consistency

commit
69138155c91df5d9535bfd16ee0979c69a518f0d
author
brice · · 2026-01-17 12:20 UTC
parent
b7d7793
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +369 -256
A internal/adapters/dto/admin_config.go +35 -0
@@ -0,0 +1,35 @@
1+package dto
2+
3+// ConfigResponse represents server configuration.
4+type ConfigResponse struct {
5+ Server ServerConfig `json:"server"`
6+ AutoRoute AutoRouteConfig `json:"auto_route"`
7+ NetworkIsolation NetworkIsolationConfig `json:"network_isolation"`
8+ Routes []Route `json:"routes"`
9+ ExternalRoutes []ExternalRoute `json:"external_routes"`
10+}
11+
12+// ServerConfig represents server config details.
13+type ServerConfig struct {
14+ Port int `json:"port"`
15+ RegistryPort int `json:"registry_port"`
16+ RegistryDomain string `json:"registry_domain"`
17+ DataDir string `json:"data_dir"`
18+}
19+
20+// AutoRouteConfig represents auto-route config details.
21+type AutoRouteConfig struct {
22+ Enabled bool `json:"enabled"`
23+}
24+
25+// NetworkIsolationConfig represents network isolation settings.
26+type NetworkIsolationConfig struct {
27+ Enabled bool `json:"enabled"`
28+ Prefix string `json:"prefix"`
29+}
30+
31+// ExternalRoute represents an external route config entry.
32+type ExternalRoute struct {
33+ Domain string `json:"domain"`
34+ Target string `json:"target"`
35+}
@@ -0,0 +1,35 @@
1+package dto
2+
3+// ConfigResponse represents server configuration.
4+type ConfigResponse struct {
5+ Server ServerConfig `json:"server"`
6+ AutoRoute AutoRouteConfig `json:"auto_route"`
7+ NetworkIsolation NetworkIsolationConfig `json:"network_isolation"`
8+ Routes []Route `json:"routes"`
9+ ExternalRoutes []ExternalRoute `json:"external_routes"`
10+}
11+
12+// ServerConfig represents server config details.
13+type ServerConfig struct {
14+ Port int `json:"port"`
15+ RegistryPort int `json:"registry_port"`
16+ RegistryDomain string `json:"registry_domain"`
17+ DataDir string `json:"data_dir"`
18+}
19+
20+// AutoRouteConfig represents auto-route config details.
21+type AutoRouteConfig struct {
22+ Enabled bool `json:"enabled"`
23+}
24+
25+// NetworkIsolationConfig represents network isolation settings.
26+type NetworkIsolationConfig struct {
27+ Enabled bool `json:"enabled"`
28+ Prefix string `json:"prefix"`
29+}
30+
31+// ExternalRoute represents an external route config entry.
32+type ExternalRoute struct {
33+ Domain string `json:"domain"`
34+ Target string `json:"target"`
35+}
A internal/adapters/dto/admin_deploy.go +8 -0
@@ -0,0 +1,8 @@
1+package dto
2+
3+// DeployResponse represents a deployment response.
4+type DeployResponse struct {
5+ Status string `json:"status"`
6+ ContainerID string `json:"container_id"`
7+ Domain string `json:"domain"`
8+}
@@ -0,0 +1,8 @@
1+package dto
2+
3+// DeployResponse represents a deployment response.
4+type DeployResponse struct {
5+ Status string `json:"status"`
6+ ContainerID string `json:"container_id"`
7+ Domain string `json:"domain"`
8+}
A internal/adapters/dto/admin_health.go +15 -0
@@ -0,0 +1,15 @@
1+package dto
2+
3+// HealthStatus represents health data for a route.
4+type HealthStatus struct {
5+ ContainerStatus string `json:"container_status"`
6+ HTTPStatus int `json:"http_status"`
7+ ResponseTimeMs int64 `json:"response_time_ms"`
8+ Healthy bool `json:"healthy"`
9+ Error string `json:"error"`
10+}
11+
12+// HealthResponse represents the health status for all routes.
13+type HealthResponse struct {
14+ Health map[string]HealthStatus `json:"health"`
15+}
@@ -0,0 +1,15 @@
1+package dto
2+
3+// HealthStatus represents health data for a route.
4+type HealthStatus struct {
5+ ContainerStatus string `json:"container_status"`
6+ HTTPStatus int `json:"http_status"`
7+ ResponseTimeMs int64 `json:"response_time_ms"`
8+ Healthy bool `json:"healthy"`
9+ Error string `json:"error"`
10+}
11+
12+// HealthResponse represents the health status for all routes.
13+type HealthResponse struct {
14+ Health map[string]HealthStatus `json:"health"`
15+}
A internal/adapters/dto/admin_logs.go +12 -0
@@ -0,0 +1,12 @@
1+package dto
2+
3+// ProcessLogsResponse represents process log response.
4+type ProcessLogsResponse struct {
5+ Lines []string `json:"lines"`
6+}
7+
8+// ContainerLogsResponse represents container log response.
9+type ContainerLogsResponse struct {
10+ Domain string `json:"domain"`
11+ Lines []string `json:"lines"`
12+}
@@ -0,0 +1,12 @@
1+package dto
2+
3+// ProcessLogsResponse represents process log response.
4+type ProcessLogsResponse struct {
5+ Lines []string `json:"lines"`
6+}
7+
8+// ContainerLogsResponse represents container log response.
9+type ContainerLogsResponse struct {
10+ Domain string `json:"domain"`
11+ Lines []string `json:"lines"`
12+}
A internal/adapters/dto/admin_networks.go +15 -0
@@ -0,0 +1,15 @@
1+package dto
2+
3+// Network represents a network in API responses.
4+type Network struct {
5+ ID string `json:"id"`
6+ Name string `json:"name"`
7+ Driver string `json:"driver"`
8+ Containers []string `json:"containers"`
9+ Labels map[string]string `json:"labels"`
10+}
11+
12+// NetworksResponse represents a list of networks.
13+type NetworksResponse struct {
14+ Networks []Network `json:"networks"`
15+}
@@ -0,0 +1,15 @@
1+package dto
2+
3+// Network represents a network in API responses.
4+type Network struct {
5+ ID string `json:"id"`
6+ Name string `json:"name"`
7+ Driver string `json:"driver"`
8+ Containers []string `json:"containers"`
9+ Labels map[string]string `json:"labels"`
10+}
11+
12+// NetworksResponse represents a list of networks.
13+type NetworksResponse struct {
14+ Networks []Network `json:"networks"`
15+}
A internal/adapters/dto/admin_reload.go +6 -0
@@ -0,0 +1,6 @@
1+package dto
2+
3+// ReloadResponse represents a reload response.
4+type ReloadResponse struct {
5+ Status string `json:"status"`
6+}
@@ -0,0 +1,6 @@
1+package dto
2+
3+// ReloadResponse represents a reload response.
4+type ReloadResponse struct {
5+ Status string `json:"status"`
6+}
A internal/adapters/dto/admin_secrets.go +12 -0
@@ -0,0 +1,12 @@
1+package dto
2+
3+// SecretsListResponse represents a list of secret keys for a domain.
4+type SecretsListResponse struct {
5+ Domain string `json:"domain"`
6+ Keys []string `json:"keys"`
7+}
8+
9+// SecretsStatusResponse represents a secrets write response.
10+type SecretsStatusResponse struct {
11+ Status string `json:"status"`
12+}
@@ -0,0 +1,12 @@
1+package dto
2+
3+// SecretsListResponse represents a list of secret keys for a domain.
4+type SecretsListResponse struct {
5+ Domain string `json:"domain"`
6+ Keys []string `json:"keys"`
7+}
8+
9+// SecretsStatusResponse represents a secrets write response.
10+type SecretsStatusResponse struct {
11+ Status string `json:"status"`
12+}
A internal/adapters/dto/admin_status.go +12 -0
@@ -0,0 +1,12 @@
1+package dto
2+
3+// StatusResponse represents admin status information.
4+type StatusResponse struct {
5+ Routes int `json:"routes"`
6+ RegistryDomain string `json:"registry_domain"`
7+ RegistryPort int `json:"registry_port"`
8+ ServerPort int `json:"server_port"`
9+ AutoRoute bool `json:"auto_route"`
10+ NetworkIsolation bool `json:"network_isolation"`
11+ ContainerStatuses map[string]string `json:"container_status"`
12+}
@@ -0,0 +1,12 @@
1+package dto
2+
3+// StatusResponse represents admin status information.
4+type StatusResponse struct {
5+ Routes int `json:"routes"`
6+ RegistryDomain string `json:"registry_domain"`
7+ RegistryPort int `json:"registry_port"`
8+ ServerPort int `json:"server_port"`
9+ AutoRoute bool `json:"auto_route"`
10+ NetworkIsolation bool `json:"network_isolation"`
11+ ContainerStatuses map[string]string `json:"container_status"`
12+}
A internal/adapters/dto/error.go +6 -0
@@ -0,0 +1,6 @@
1+package dto
2+
3+// ErrorResponse represents a common API error response.
4+type ErrorResponse struct {
5+ Error string `json:"error"`
6+}
@@ -0,0 +1,6 @@
1+package dto
2+
3+// ErrorResponse represents a common API error response.
4+type ErrorResponse struct {
5+ Error string `json:"error"`
6+}
A internal/adapters/dto/registry_errors.go +12 -0
@@ -0,0 +1,12 @@
1+package dto
2+
3+// RegistryErrorItem represents an individual registry error.
4+type RegistryErrorItem struct {
5+ Code string `json:"code"`
6+ Message string `json:"message"`
7+}
8+
9+// RegistryErrorResponse represents registry errors.
10+type RegistryErrorResponse struct {
11+ Errors []RegistryErrorItem `json:"errors"`
12+}
@@ -0,0 +1,12 @@
1+package dto
2+
3+// RegistryErrorItem represents an individual registry error.
4+type RegistryErrorItem struct {
5+ Code string `json:"code"`
6+ Message string `json:"message"`
7+}
8+
9+// RegistryErrorResponse represents registry errors.
10+type RegistryErrorResponse struct {
11+ Errors []RegistryErrorItem `json:"errors"`
12+}
A internal/adapters/dto/registry_tags.go +7 -0
@@ -0,0 +1,7 @@
1+package dto
2+
3+// TagListResponse represents registry tag list response.
4+type TagListResponse struct {
5+ Name string `json:"name"`
6+ Tags []string `json:"tags"`
7+}
@@ -0,0 +1,7 @@
1+package dto
2+
3+// TagListResponse represents registry tag list response.
4+type TagListResponse struct {
5+ Name string `json:"name"`
6+ Tags []string `json:"tags"`
7+}
A internal/adapters/dto/registry_token.go +9 -0
@@ -0,0 +1,9 @@
1+package dto
2+
3+// TokenResponse represents the response from the token server.
4+type TokenResponse struct {
5+ Token string `json:"token"`
6+ AccessToken string `json:"access_token,omitempty"`
7+ ExpiresIn int `json:"expires_in,omitempty"`
8+ IssuedAt string `json:"issued_at,omitempty"`
9+}
@@ -0,0 +1,9 @@
1+package dto
2+
3+// TokenResponse represents the response from the token server.
4+type TokenResponse struct {
5+ Token string `json:"token"`
6+ AccessToken string `json:"access_token,omitempty"`
7+ ExpiresIn int `json:"expires_in,omitempty"`
8+ IssuedAt string `json:"issued_at,omitempty"`
9+}
M internal/adapters/dto/route.go +27 -0
@@ -1,6 +1,13 @@
11 // Package dto provides shared data transfer objects for API responses.
22 package dto
33
4+// Route represents route configuration in API responses.
5+type Route struct {
6+ Domain string `json:"domain"`
7+ Image string `json:"image"`
8+ HTTPS bool `json:"https"`
9+}
10+
411 // RouteInfo represents route details in API responses.
512 type RouteInfo struct {
613 Domain string `json:"domain"`
@@ -18,3 +25,23 @@ type Attachment struct {
1825 ContainerID string `json:"container_id"`
1926 Status string `json:"status"`
2027 }
28+
29+// RoutesResponse represents a list of routes.
30+type RoutesResponse struct {
31+ Routes []Route `json:"routes"`
32+}
33+
34+// RoutesDetailResponse represents a detailed list of routes.
35+type RoutesDetailResponse struct {
36+ Routes []RouteInfo `json:"routes"`
37+}
38+
39+// AttachmentsResponse represents a list of attachments for a route.
40+type AttachmentsResponse struct {
41+ Attachments []Attachment `json:"attachments"`
42+}
43+
44+// RouteDeleteResponse represents a route removal response.
45+type RouteDeleteResponse struct {
46+ Status string `json:"status"`
47+}
@@ -1,6 +1,13 @@
1 // Package dto provides shared data transfer objects for API responses.1 // Package dto provides shared data transfer objects for API responses.
2 package dto2 package dto
3 3
4+// Route represents route configuration in API responses.
5+type Route struct {
6+ Domain string `json:"domain"`
7+ Image string `json:"image"`
8+ HTTPS bool `json:"https"`
9+}
10+
4 // RouteInfo represents route details in API responses.11 // RouteInfo represents route details in API responses.
5 type RouteInfo struct {12 type RouteInfo struct {
6 Domain string `json:"domain"`13 Domain string `json:"domain"`
@@ -18,3 +25,23 @@ type Attachment struct {
18 ContainerID string `json:"container_id"`25 ContainerID string `json:"container_id"`
19 Status string `json:"status"`26 Status string `json:"status"`
20 }27 }
28+
29+// RoutesResponse represents a list of routes.
30+type RoutesResponse struct {
31+ Routes []Route `json:"routes"`
32+}
33+
34+// RoutesDetailResponse represents a detailed list of routes.
35+type RoutesDetailResponse struct {
36+ Routes []RouteInfo `json:"routes"`
37+}
38+
39+// AttachmentsResponse represents a list of attachments for a route.
40+type AttachmentsResponse struct {
41+ Attachments []Attachment `json:"attachments"`
42+}
43+
44+// RouteDeleteResponse represents a route removal response.
45+type RouteDeleteResponse struct {
46+ Status string `json:"status"`
47+}
M internal/adapters/in/http/admin/handler.go +98 -55
@@ -38,6 +38,7 @@ type Handler struct {
3838 // Type aliases for API responses using shared DTO types.
3939 type routeInfoResponse = dto.RouteInfo
4040 type attachmentResponse = dto.Attachment
41+type routeResponse = dto.Route
4142
4243 // toAttachmentResponse converts a domain.Attachment to a dto.Attachment.
4344 func toAttachmentResponse(a domain.Attachment) dto.Attachment {
@@ -65,6 +66,15 @@ func toRouteInfoResponse(r domain.RouteInfo) dto.RouteInfo {
6566 }
6667 }
6768
69+// toRouteResponse converts a domain.Route to a dto.Route.
70+func toRouteResponse(r domain.Route) dto.Route {
71+ return dto.Route{
72+ Domain: r.Domain,
73+ Image: r.Image,
74+ HTTPS: r.HTTPS,
75+ }
76+}
77+
6878 // NewHandler creates a new admin HTTP handler.
6979 func NewHandler(
7080 configSvc in.ConfigService,
@@ -131,7 +141,7 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
131141 case path == "/config":
132142 h.handleConfig(w, r)
133143 default:
134- http.NotFound(w, r)
144+ h.sendError(w, http.StatusNotFound, "route not found")
135145 }
136146 }
137147
@@ -146,7 +156,7 @@ func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
146156
147157 // sendError sends an error response.
148158 func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
149- h.sendJSON(w, status, map[string]string{"error": message})
159+ h.sendJSON(w, status, dto.ErrorResponse{Error: message})
150160 }
151161
152162 // handleRoutes handles /admin/routes endpoints.
@@ -187,12 +197,16 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
187197 for _, route := range routes {
188198 response = append(response, toRouteInfoResponse(route))
189199 }
190- h.sendJSON(w, http.StatusOK, map[string]any{"routes": response})
200+ h.sendJSON(w, http.StatusOK, dto.RoutesDetailResponse{Routes: response})
191201 return
192202 }
193203
194204 routes := h.configSvc.GetRoutes(ctx)
195- h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes})
205+ response := make([]routeResponse, 0, len(routes))
206+ for _, route := range routes {
207+ response = append(response, toRouteResponse(route))
208+ }
209+ h.sendJSON(w, http.StatusOK, dto.RoutesResponse{Routes: response})
196210 return
197211 }
198212
@@ -207,7 +221,7 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
207221 for _, attachment := range attachments {
208222 response = append(response, toAttachmentResponse(attachment))
209223 }
210- h.sendJSON(w, http.StatusOK, map[string]any{"attachments": response})
224+ h.sendJSON(w, http.StatusOK, dto.AttachmentsResponse{Attachments: response})
211225 return
212226 }
213227
@@ -216,7 +230,7 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
216230 h.sendError(w, http.StatusNotFound, "route not found")
217231 return
218232 }
219- h.sendJSON(w, http.StatusOK, route)
233+ h.sendJSON(w, http.StatusOK, toRouteResponse(*route))
220234 }
221235
222236 func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
@@ -251,7 +265,7 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
251265 }
252266
253267 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
254- h.sendJSON(w, http.StatusCreated, route)
268+ h.sendJSON(w, http.StatusCreated, toRouteResponse(route))
255269 }
256270
257271 func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
@@ -295,7 +309,7 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
295309 }
296310
297311 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
298- h.sendJSON(w, http.StatusOK, route)
312+ h.sendJSON(w, http.StatusOK, toRouteResponse(route))
299313 }
300314
301315 func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
@@ -320,7 +334,7 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
320334 }
321335
322336 log.Info().Str("domain", routeDomain).Msg("route removed")
323- h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"})
337+ h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})
324338 }
325339
326340 func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
@@ -342,7 +356,21 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
342356 return
343357 }
344358
345- h.sendJSON(w, http.StatusOK, map[string]any{"networks": networks})
359+ response := make([]dto.Network, 0, len(networks))
360+ for _, network := range networks {
361+ if network == nil {
362+ continue
363+ }
364+ response = append(response, dto.Network{
365+ ID: network.ID,
366+ Name: network.Name,
367+ Driver: network.Driver,
368+ Containers: append([]string{}, network.Containers...),
369+ Labels: network.Labels,
370+ })
371+ }
372+
373+ h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
346374 }
347375
348376 // handleSecrets handles /admin/secrets endpoints.
@@ -377,7 +405,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
377405 h.sendError(w, http.StatusBadRequest, "invalid domain")
378406 return
379407 }
380- h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": keys})
408+ h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{Domain: secretDomain, Keys: keys})
381409
382410 case http.MethodPost:
383411 // Check write permission
@@ -404,7 +432,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
404432 }
405433
406434 log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
407- h.sendJSON(w, http.StatusOK, map[string]string{"status": "updated"})
435+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
408436
409437 case http.MethodDelete:
410438 // Check write permission
@@ -424,7 +452,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
424452 }
425453
426454 log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
427- h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
455+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
428456
429457 default:
430458 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
@@ -449,19 +477,21 @@ func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
449477
450478 health := h.healthSvc.CheckAllRoutes(ctx)
451479
452- // Convert to JSON-friendly format
453- result := make(map[string]any, len(health))
454- for domain, h := range health {
455- result[domain] = map[string]any{
456- "container_status": h.ContainerStatus,
457- "http_status": h.HTTPStatus,
458- "response_time_ms": h.ResponseTimeMs,
459- "healthy": h.Healthy,
460- "error": h.Error,
480+ response := make(map[string]dto.HealthStatus, len(health))
481+ for domain, healthStatus := range health {
482+ if healthStatus == nil {
483+ continue
484+ }
485+ response[domain] = dto.HealthStatus{
486+ ContainerStatus: healthStatus.ContainerStatus,
487+ HTTPStatus: healthStatus.HTTPStatus,
488+ ResponseTimeMs: healthStatus.ResponseTimeMs,
489+ Healthy: healthStatus.Healthy,
490+ Error: healthStatus.Error,
461491 }
462492 }
463493
464- h.sendJSON(w, http.StatusOK, map[string]any{"health": result})
494+ h.sendJSON(w, http.StatusOK, dto.HealthResponse{Health: response})
465495 }
466496
467497 // handleStatus handles /admin/status endpoint.
@@ -492,14 +522,14 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
492522 statuses[route.Domain] = status
493523 }
494524
495- status := map[string]any{
496- "routes": len(routes),
497- "registry_domain": h.configSvc.GetRegistryDomain(),
498- "registry_port": h.configSvc.GetRegistryPort(),
499- "server_port": h.configSvc.GetServerPort(),
500- "auto_route": h.configSvc.IsAutoRouteEnabled(),
501- "network_isolation": h.configSvc.IsNetworkIsolationEnabled(),
502- "container_status": statuses,
525+ status := dto.StatusResponse{
526+ Routes: len(routes),
527+ RegistryDomain: h.configSvc.GetRegistryDomain(),
528+ RegistryPort: h.configSvc.GetRegistryPort(),
529+ ServerPort: h.configSvc.GetServerPort(),
530+ AutoRoute: h.configSvc.IsAutoRouteEnabled(),
531+ NetworkIsolation: h.configSvc.IsNetworkIsolationEnabled(),
532+ ContainerStatuses: statuses,
503533 }
504534
505535 h.sendJSON(w, http.StatusOK, status)
@@ -538,7 +568,7 @@ func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
538568 }
539569
540570 log.Info().Msg("config reloaded via admin API")
541- h.sendJSON(w, http.StatusOK, map[string]string{"status": "reloaded"})
571+ h.sendJSON(w, http.StatusOK, dto.ReloadResponse{Status: "reloaded"})
542572 }
543573
544574 // handleConfig handles /admin/config endpoint.
@@ -556,22 +586,37 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
556586 return
557587 }
558588
559- config := map[string]any{
560- "server": map[string]any{
561- "port": h.configSvc.GetServerPort(),
562- "registry_port": h.configSvc.GetRegistryPort(),
563- "registry_domain": h.configSvc.GetRegistryDomain(),
564- "data_dir": h.configSvc.GetDataDir(),
589+ routes := h.configSvc.GetRoutes(ctx)
590+ routeResponses := make([]dto.Route, 0, len(routes))
591+ for _, route := range routes {
592+ routeResponses = append(routeResponses, toRouteResponse(route))
593+ }
594+
595+ externalRoutes := h.configSvc.GetExternalRoutes()
596+ externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
597+ for domain, target := range externalRoutes {
598+ externalResponses = append(externalResponses, dto.ExternalRoute{
599+ Domain: domain,
600+ Target: target,
601+ })
602+ }
603+
604+ config := dto.ConfigResponse{
605+ Server: dto.ServerConfig{
606+ Port: h.configSvc.GetServerPort(),
607+ RegistryPort: h.configSvc.GetRegistryPort(),
608+ RegistryDomain: h.configSvc.GetRegistryDomain(),
609+ DataDir: h.configSvc.GetDataDir(),
565610 },
566- "auto_route": map[string]any{
567- "enabled": h.configSvc.IsAutoRouteEnabled(),
611+ AutoRoute: dto.AutoRouteConfig{
612+ Enabled: h.configSvc.IsAutoRouteEnabled(),
568613 },
569- "network_isolation": map[string]any{
570- "enabled": h.configSvc.IsNetworkIsolationEnabled(),
571- "prefix": h.configSvc.GetNetworkPrefix(),
614+ NetworkIsolation: dto.NetworkIsolationConfig{
615+ Enabled: h.configSvc.IsNetworkIsolationEnabled(),
616+ Prefix: h.configSvc.GetNetworkPrefix(),
572617 },
573- "routes": h.configSvc.GetRoutes(ctx),
574- "external_routes": h.configSvc.GetExternalRoutes(),
618+ Routes: routeResponses,
619+ ExternalRoutes: externalResponses,
575620 }
576621
577622 h.sendJSON(w, http.StatusOK, config)
@@ -617,10 +662,10 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
617662 }
618663
619664 log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
620- h.sendJSON(w, http.StatusOK, map[string]any{
621- "status": "deployed",
622- "container_id": container.ID,
623- "domain": deployDomain,
665+ h.sendJSON(w, http.StatusOK, dto.DeployResponse{
666+ Status: "deployed",
667+ ContainerID: container.ID,
668+ Domain: deployDomain,
624669 })
625670 }
626671
@@ -697,9 +742,7 @@ func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, line
697742 return
698743 }
699744
700- h.sendJSON(w, http.StatusOK, map[string]any{
701- "lines": logLines,
702- })
745+ h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})
703746 }
704747
705748 // handleContainerLogs handles container logs for a specific domain.
@@ -721,9 +764,9 @@ func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, lo
721764 return
722765 }
723766
724- h.sendJSON(w, http.StatusOK, map[string]any{
725- "domain": logDomain,
726- "lines": logLines,
767+ h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
768+ Domain: logDomain,
769+ Lines: logLines,
727770 })
728771 }
729772
@@ -38,6 +38,7 @@ type Handler struct {
38 // Type aliases for API responses using shared DTO types.38 // Type aliases for API responses using shared DTO types.
39 type routeInfoResponse = dto.RouteInfo39 type routeInfoResponse = dto.RouteInfo
40 type attachmentResponse = dto.Attachment40 type attachmentResponse = dto.Attachment
41+type routeResponse = dto.Route
41 42
42 // toAttachmentResponse converts a domain.Attachment to a dto.Attachment.43 // toAttachmentResponse converts a domain.Attachment to a dto.Attachment.
43 func toAttachmentResponse(a domain.Attachment) dto.Attachment {44 func toAttachmentResponse(a domain.Attachment) dto.Attachment {
@@ -65,6 +66,15 @@ func toRouteInfoResponse(r domain.RouteInfo) dto.RouteInfo {
65 }66 }
66 }67 }
67 68
69+// toRouteResponse converts a domain.Route to a dto.Route.
70+func toRouteResponse(r domain.Route) dto.Route {
71+ return dto.Route{
72+ Domain: r.Domain,
73+ Image: r.Image,
74+ HTTPS: r.HTTPS,
75+ }
76+}
77+
68 // NewHandler creates a new admin HTTP handler.78 // NewHandler creates a new admin HTTP handler.
69 func NewHandler(79 func NewHandler(
70 configSvc in.ConfigService,80 configSvc in.ConfigService,
@@ -131,7 +141,7 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
131 case path == "/config":141 case path == "/config":
132 h.handleConfig(w, r)142 h.handleConfig(w, r)
133 default:143 default:
134- http.NotFound(w, r)144+ h.sendError(w, http.StatusNotFound, "route not found")
135 }145 }
136 }146 }
137 147
@@ -146,7 +156,7 @@ func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
146 156
147 // sendError sends an error response.157 // sendError sends an error response.
148 func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {158 func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
149- h.sendJSON(w, status, map[string]string{"error": message})159+ h.sendJSON(w, status, dto.ErrorResponse{Error: message})
150 }160 }
151 161
152 // handleRoutes handles /admin/routes endpoints.162 // handleRoutes handles /admin/routes endpoints.
@@ -187,12 +197,16 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
187 for _, route := range routes {197 for _, route := range routes {
188 response = append(response, toRouteInfoResponse(route))198 response = append(response, toRouteInfoResponse(route))
189 }199 }
190- h.sendJSON(w, http.StatusOK, map[string]any{"routes": response})200+ h.sendJSON(w, http.StatusOK, dto.RoutesDetailResponse{Routes: response})
191 return201 return
192 }202 }
193 203
194 routes := h.configSvc.GetRoutes(ctx)204 routes := h.configSvc.GetRoutes(ctx)
195- h.sendJSON(w, http.StatusOK, map[string]any{"routes": routes})205+ response := make([]routeResponse, 0, len(routes))
206+ for _, route := range routes {
207+ response = append(response, toRouteResponse(route))
208+ }
209+ h.sendJSON(w, http.StatusOK, dto.RoutesResponse{Routes: response})
196 return210 return
197 }211 }
198 212
@@ -207,7 +221,7 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
207 for _, attachment := range attachments {221 for _, attachment := range attachments {
208 response = append(response, toAttachmentResponse(attachment))222 response = append(response, toAttachmentResponse(attachment))
209 }223 }
210- h.sendJSON(w, http.StatusOK, map[string]any{"attachments": response})224+ h.sendJSON(w, http.StatusOK, dto.AttachmentsResponse{Attachments: response})
211 return225 return
212 }226 }
213 227
@@ -216,7 +230,7 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
216 h.sendError(w, http.StatusNotFound, "route not found")230 h.sendError(w, http.StatusNotFound, "route not found")
217 return231 return
218 }232 }
219- h.sendJSON(w, http.StatusOK, route)233+ h.sendJSON(w, http.StatusOK, toRouteResponse(*route))
220 }234 }
221 235
222 func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {236 func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
@@ -251,7 +265,7 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
251 }265 }
252 266
253 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")267 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route added")
254- h.sendJSON(w, http.StatusCreated, route)268+ h.sendJSON(w, http.StatusCreated, toRouteResponse(route))
255 }269 }
256 270
257 func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {271 func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeDomain string) {
@@ -295,7 +309,7 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
295 }309 }
296 310
297 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")311 log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("route updated")
298- h.sendJSON(w, http.StatusOK, route)312+ h.sendJSON(w, http.StatusOK, toRouteResponse(route))
299 }313 }
300 314
301 func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {315 func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, routeDomain string) {
@@ -320,7 +334,7 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
320 }334 }
321 335
322 log.Info().Str("domain", routeDomain).Msg("route removed")336 log.Info().Str("domain", routeDomain).Msg("route removed")
323- h.sendJSON(w, http.StatusOK, map[string]string{"status": "removed"})337+ h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"})
324 }338 }
325 339
326 func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {340 func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
@@ -342,7 +356,21 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
342 return356 return
343 }357 }
344 358
345- h.sendJSON(w, http.StatusOK, map[string]any{"networks": networks})359+ response := make([]dto.Network, 0, len(networks))
360+ for _, network := range networks {
361+ if network == nil {
362+ continue
363+ }
364+ response = append(response, dto.Network{
365+ ID: network.ID,
366+ Name: network.Name,
367+ Driver: network.Driver,
368+ Containers: append([]string{}, network.Containers...),
369+ Labels: network.Labels,
370+ })
371+ }
372+
373+ h.sendJSON(w, http.StatusOK, dto.NetworksResponse{Networks: response})
346 }374 }
347 375
348 // handleSecrets handles /admin/secrets endpoints.376 // handleSecrets handles /admin/secrets endpoints.
@@ -377,7 +405,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
377 h.sendError(w, http.StatusBadRequest, "invalid domain")405 h.sendError(w, http.StatusBadRequest, "invalid domain")
378 return406 return
379 }407 }
380- h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": keys})408+ h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{Domain: secretDomain, Keys: keys})
381 409
382 case http.MethodPost:410 case http.MethodPost:
383 // Check write permission411 // Check write permission
@@ -404,7 +432,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
404 }432 }
405 433
406 log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")434 log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
407- h.sendJSON(w, http.StatusOK, map[string]string{"status": "updated"})435+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
408 436
409 case http.MethodDelete:437 case http.MethodDelete:
410 // Check write permission438 // Check write permission
@@ -424,7 +452,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
424 }452 }
425 453
426 log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")454 log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
427- h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted"})455+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
428 456
429 default:457 default:
430 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")458 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
@@ -449,19 +477,21 @@ func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
449 477
450 health := h.healthSvc.CheckAllRoutes(ctx)478 health := h.healthSvc.CheckAllRoutes(ctx)
451 479
452- // Convert to JSON-friendly format480+ response := make(map[string]dto.HealthStatus, len(health))
453- result := make(map[string]any, len(health))481+ for domain, healthStatus := range health {
454- for domain, h := range health {482+ if healthStatus == nil {
455- result[domain] = map[string]any{483+ continue
456- "container_status": h.ContainerStatus,484+ }
457- "http_status": h.HTTPStatus,485+ response[domain] = dto.HealthStatus{
458- "response_time_ms": h.ResponseTimeMs,486+ ContainerStatus: healthStatus.ContainerStatus,
459- "healthy": h.Healthy,487+ HTTPStatus: healthStatus.HTTPStatus,
460- "error": h.Error,488+ ResponseTimeMs: healthStatus.ResponseTimeMs,
489+ Healthy: healthStatus.Healthy,
490+ Error: healthStatus.Error,
461 }491 }
462 }492 }
463 493
464- h.sendJSON(w, http.StatusOK, map[string]any{"health": result})494+ h.sendJSON(w, http.StatusOK, dto.HealthResponse{Health: response})
465 }495 }
466 496
467 // handleStatus handles /admin/status endpoint.497 // handleStatus handles /admin/status endpoint.
@@ -492,14 +522,14 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
492 statuses[route.Domain] = status522 statuses[route.Domain] = status
493 }523 }
494 524
495- status := map[string]any{525+ status := dto.StatusResponse{
496- "routes": len(routes),526+ Routes: len(routes),
497- "registry_domain": h.configSvc.GetRegistryDomain(),527+ RegistryDomain: h.configSvc.GetRegistryDomain(),
498- "registry_port": h.configSvc.GetRegistryPort(),528+ RegistryPort: h.configSvc.GetRegistryPort(),
499- "server_port": h.configSvc.GetServerPort(),529+ ServerPort: h.configSvc.GetServerPort(),
500- "auto_route": h.configSvc.IsAutoRouteEnabled(),530+ AutoRoute: h.configSvc.IsAutoRouteEnabled(),
501- "network_isolation": h.configSvc.IsNetworkIsolationEnabled(),531+ NetworkIsolation: h.configSvc.IsNetworkIsolationEnabled(),
502- "container_status": statuses,532+ ContainerStatuses: statuses,
503 }533 }
504 534
505 h.sendJSON(w, http.StatusOK, status)535 h.sendJSON(w, http.StatusOK, status)
@@ -538,7 +568,7 @@ func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
538 }568 }
539 569
540 log.Info().Msg("config reloaded via admin API")570 log.Info().Msg("config reloaded via admin API")
541- h.sendJSON(w, http.StatusOK, map[string]string{"status": "reloaded"})571+ h.sendJSON(w, http.StatusOK, dto.ReloadResponse{Status: "reloaded"})
542 }572 }
543 573
544 // handleConfig handles /admin/config endpoint.574 // handleConfig handles /admin/config endpoint.
@@ -556,22 +586,37 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
556 return586 return
557 }587 }
558 588
559- config := map[string]any{589+ routes := h.configSvc.GetRoutes(ctx)
560- "server": map[string]any{590+ routeResponses := make([]dto.Route, 0, len(routes))
561- "port": h.configSvc.GetServerPort(),591+ for _, route := range routes {
562- "registry_port": h.configSvc.GetRegistryPort(),592+ routeResponses = append(routeResponses, toRouteResponse(route))
563- "registry_domain": h.configSvc.GetRegistryDomain(),593+ }
564- "data_dir": h.configSvc.GetDataDir(),594+
595+ externalRoutes := h.configSvc.GetExternalRoutes()
596+ externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
597+ for domain, target := range externalRoutes {
598+ externalResponses = append(externalResponses, dto.ExternalRoute{
599+ Domain: domain,
600+ Target: target,
601+ })
602+ }
603+
604+ config := dto.ConfigResponse{
605+ Server: dto.ServerConfig{
606+ Port: h.configSvc.GetServerPort(),
607+ RegistryPort: h.configSvc.GetRegistryPort(),
608+ RegistryDomain: h.configSvc.GetRegistryDomain(),
609+ DataDir: h.configSvc.GetDataDir(),
565 },610 },
566- "auto_route": map[string]any{611+ AutoRoute: dto.AutoRouteConfig{
567- "enabled": h.configSvc.IsAutoRouteEnabled(),612+ Enabled: h.configSvc.IsAutoRouteEnabled(),
568 },613 },
569- "network_isolation": map[string]any{614+ NetworkIsolation: dto.NetworkIsolationConfig{
570- "enabled": h.configSvc.IsNetworkIsolationEnabled(),615+ Enabled: h.configSvc.IsNetworkIsolationEnabled(),
571- "prefix": h.configSvc.GetNetworkPrefix(),616+ Prefix: h.configSvc.GetNetworkPrefix(),
572 },617 },
573- "routes": h.configSvc.GetRoutes(ctx),618+ Routes: routeResponses,
574- "external_routes": h.configSvc.GetExternalRoutes(),619+ ExternalRoutes: externalResponses,
575 }620 }
576 621
577 h.sendJSON(w, http.StatusOK, config)622 h.sendJSON(w, http.StatusOK, config)
@@ -617,10 +662,10 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
617 }662 }
618 663
619 log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")664 log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
620- h.sendJSON(w, http.StatusOK, map[string]any{665+ h.sendJSON(w, http.StatusOK, dto.DeployResponse{
621- "status": "deployed",666+ Status: "deployed",
622- "container_id": container.ID,667+ ContainerID: container.ID,
623- "domain": deployDomain,668+ Domain: deployDomain,
624 })669 })
625 }670 }
626 671
@@ -697,9 +742,7 @@ func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, line
697 return742 return
698 }743 }
699 744
700- h.sendJSON(w, http.StatusOK, map[string]any{745+ h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})
701- "lines": logLines,
702- })
703 }746 }
704 747
705 // handleContainerLogs handles container logs for a specific domain.748 // handleContainerLogs handles container logs for a specific domain.
@@ -721,9 +764,9 @@ func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, lo
721 return764 return
722 }765 }
723 766
724- h.sendJSON(w, http.StatusOK, map[string]any{767+ h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
725- "domain": logDomain,768+ Domain: logDomain,
726- "lines": logLines,769+ Lines: logLines,
727 })770 })
728 }771 }
729 772
M internal/adapters/in/http/admin/handler_test.go +13 -5
@@ -573,13 +573,17 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
573573
574574 assert.Equal(t, http.StatusOK, rec.Code)
575575
576- var response map[string]any
576+ var response struct {
577+ Routes []struct {
578+ Domain string `json:"domain"`
579+ Image string `json:"image"`
580+ HTTPS bool `json:"https"`
581+ } `json:"routes"`
582+ }
577583 err := json.NewDecoder(rec.Body).Decode(&response)
578584 assert.NoError(t, err)
579585
580- routes, ok := response["routes"].([]any)
581- assert.True(t, ok)
582- assert.Len(t, routes, 2)
586+ assert.Len(t, response.Routes, 2)
583587 }
584588
585589 func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
@@ -602,7 +606,11 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
602606
603607 assert.Equal(t, http.StatusOK, rec.Code)
604608
605- var route domain.Route
609+ var route struct {
610+ Domain string `json:"domain"`
611+ Image string `json:"image"`
612+ HTTPS bool `json:"https"`
613+ }
606614 err := json.NewDecoder(rec.Body).Decode(&route)
607615 assert.NoError(t, err)
608616 assert.Equal(t, "app.example.com", route.Domain)
@@ -573,13 +573,17 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
573 573
574 assert.Equal(t, http.StatusOK, rec.Code)574 assert.Equal(t, http.StatusOK, rec.Code)
575 575
576- var response map[string]any576+ var response struct {
577+ Routes []struct {
578+ Domain string `json:"domain"`
579+ Image string `json:"image"`
580+ HTTPS bool `json:"https"`
581+ } `json:"routes"`
582+ }
577 err := json.NewDecoder(rec.Body).Decode(&response)583 err := json.NewDecoder(rec.Body).Decode(&response)
578 assert.NoError(t, err)584 assert.NoError(t, err)
579 585
580- routes, ok := response["routes"].([]any)586+ assert.Len(t, response.Routes, 2)
581- assert.True(t, ok)
582- assert.Len(t, routes, 2)
583 }587 }
584 588
585 func TestHandler_RoutesGet_SingleRoute(t *testing.T) {589 func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
@@ -602,7 +606,11 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
602 606
603 assert.Equal(t, http.StatusOK, rec.Code)607 assert.Equal(t, http.StatusOK, rec.Code)
604 608
605- var route domain.Route609+ var route struct {
610+ Domain string `json:"domain"`
611+ Image string `json:"image"`
612+ HTTPS bool `json:"https"`
613+ }
606 err := json.NewDecoder(rec.Body).Decode(&route)614 err := json.NewDecoder(rec.Body).Decode(&route)
607 assert.NoError(t, err)615 assert.NoError(t, err)
608 assert.Equal(t, "app.example.com", route.Domain)616 assert.Equal(t, "app.example.com", route.Domain)
M internal/adapters/in/http/admin/middleware.go +4 -3
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/zerowrap"
1010 "golang.org/x/time/rate"
1111
12+ "gordon/internal/adapters/dto"
1213 "gordon/internal/boundaries/in"
1314 "gordon/internal/domain"
1415 )
@@ -35,7 +36,7 @@ func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.
3536 w.Header().Set("Content-Type", "application/json")
3637 w.Header().Set("Retry-After", "1")
3738 w.WriteHeader(http.StatusTooManyRequests)
38- _ = json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"})
39+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
3940 return
4041 }
4142
@@ -142,11 +143,11 @@ func sendUnauthorized(w http.ResponseWriter, message string) {
142143 w.Header().Set("Content-Type", "application/json")
143144 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
144145 w.WriteHeader(http.StatusUnauthorized)
145- _ = json.NewEncoder(w).Encode(map[string]string{"error": message})
146+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
146147 }
147148
148149 func sendForbidden(w http.ResponseWriter, message string) {
149150 w.Header().Set("Content-Type", "application/json")
150151 w.WriteHeader(http.StatusForbidden)
151- _ = json.NewEncoder(w).Encode(map[string]string{"error": message})
152+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
152153 }
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
10 "golang.org/x/time/rate"10 "golang.org/x/time/rate"
11 11
12+ "gordon/internal/adapters/dto"
12 "gordon/internal/boundaries/in"13 "gordon/internal/boundaries/in"
13 "gordon/internal/domain"14 "gordon/internal/domain"
14 )15 )
@@ -35,7 +36,7 @@ func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.
35 w.Header().Set("Content-Type", "application/json")36 w.Header().Set("Content-Type", "application/json")
36 w.Header().Set("Retry-After", "1")37 w.Header().Set("Retry-After", "1")
37 w.WriteHeader(http.StatusTooManyRequests)38 w.WriteHeader(http.StatusTooManyRequests)
38- _ = json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"})39+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
39 return40 return
40 }41 }
41 42
@@ -142,11 +143,11 @@ func sendUnauthorized(w http.ResponseWriter, message string) {
142 w.Header().Set("Content-Type", "application/json")143 w.Header().Set("Content-Type", "application/json")
143 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)144 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
144 w.WriteHeader(http.StatusUnauthorized)145 w.WriteHeader(http.StatusUnauthorized)
145- _ = json.NewEncoder(w).Encode(map[string]string{"error": message})146+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
146 }147 }
147 148
148 func sendForbidden(w http.ResponseWriter, message string) {149 func sendForbidden(w http.ResponseWriter, message string) {
149 w.Header().Set("Content-Type", "application/json")150 w.Header().Set("Content-Type", "application/json")
150 w.WriteHeader(http.StatusForbidden)151 w.WriteHeader(http.StatusForbidden)
151- _ = json.NewEncoder(w).Encode(map[string]string{"error": message})152+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
152 }153 }
M internal/adapters/in/http/middleware/auth.go +11 -3
@@ -3,11 +3,13 @@ package middleware
33 import (
44 "context"
55 "crypto/subtle"
6+ "encoding/json"
67 "net/http"
78 "strings"
89
910 "github.com/bnema/zerowrap"
1011
12+ "gordon/internal/adapters/dto"
1113 "gordon/internal/boundaries/in"
1214 "gordon/internal/domain"
1315 )
@@ -26,7 +28,9 @@ func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Hand
2628 if !isAuthenticated(r, username, password, log) {
2729 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
2830 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
29- http.Error(w, "Unauthorized", http.StatusUnauthorized)
31+ w.Header().Set("Content-Type", "application/json")
32+ w.WriteHeader(http.StatusUnauthorized)
33+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
3034
3135 log.Warn().
3236 Str(zerowrap.FieldLayer, "adapter").
@@ -364,7 +368,9 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
364368 // sendForbidden sends an HTTP 403 response.
365369 func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) {
366370 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
367- http.Error(w, "Forbidden: insufficient scope", http.StatusForbidden)
371+ w.Header().Set("Content-Type", "application/json")
372+ w.WriteHeader(http.StatusForbidden)
373+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden: insufficient scope"})
368374
369375 log.Warn().
370376 Str(zerowrap.FieldLayer, "adapter").
@@ -400,7 +406,9 @@ func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host stri
400406 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
401407 }
402408
403- http.Error(w, "Unauthorized", http.StatusUnauthorized)
409+ w.Header().Set("Content-Type", "application/json")
410+ w.WriteHeader(http.StatusUnauthorized)
411+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
404412
405413 log.Warn().
406414 Str(zerowrap.FieldLayer, "adapter").
@@ -3,11 +3,13 @@ package middleware
3 import (3 import (
4 "context"4 "context"
5 "crypto/subtle"5 "crypto/subtle"
6+ "encoding/json"
6 "net/http"7 "net/http"
7 "strings"8 "strings"
8 9
9 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
10 11
12+ "gordon/internal/adapters/dto"
11 "gordon/internal/boundaries/in"13 "gordon/internal/boundaries/in"
12 "gordon/internal/domain"14 "gordon/internal/domain"
13 )15 )
@@ -26,7 +28,9 @@ func RegistryAuth(username, password string, log zerowrap.Logger) func(http.Hand
26 if !isAuthenticated(r, username, password, log) {28 if !isAuthenticated(r, username, password, log) {
27 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)29 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
28 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")30 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
29- http.Error(w, "Unauthorized", http.StatusUnauthorized)31+ w.Header().Set("Content-Type", "application/json")
32+ w.WriteHeader(http.StatusUnauthorized)
33+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
30 34
31 log.Warn().35 log.Warn().
32 Str(zerowrap.FieldLayer, "adapter").36 Str(zerowrap.FieldLayer, "adapter").
@@ -364,7 +368,9 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
364 // sendForbidden sends an HTTP 403 response.368 // sendForbidden sends an HTTP 403 response.
365 func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) {369 func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) {
366 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")370 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
367- http.Error(w, "Forbidden: insufficient scope", http.StatusForbidden)371+ w.Header().Set("Content-Type", "application/json")
372+ w.WriteHeader(http.StatusForbidden)
373+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden: insufficient scope"})
368 374
369 log.Warn().375 log.Warn().
370 Str(zerowrap.FieldLayer, "adapter").376 Str(zerowrap.FieldLayer, "adapter").
@@ -400,7 +406,9 @@ func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host stri
400 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)406 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
401 }407 }
402 408
403- http.Error(w, "Unauthorized", http.StatusUnauthorized)409+ w.Header().Set("Content-Type", "application/json")
410+ w.WriteHeader(http.StatusUnauthorized)
411+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
404 412
405 log.Warn().413 log.Warn().
406 Str(zerowrap.FieldLayer, "adapter").414 Str(zerowrap.FieldLayer, "adapter").
M internal/adapters/in/http/middleware/logging.go +6 -1
@@ -2,10 +2,13 @@
22 package middleware
33
44 import (
5+ "encoding/json"
56 "net/http"
67 "time"
78
89 "github.com/bnema/zerowrap"
10+
11+ "gordon/internal/adapters/dto"
912 )
1013
1114 // ResponseWriter wraps http.ResponseWriter to capture status code and bytes written.
@@ -131,7 +134,9 @@ func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler {
131134 Str(zerowrap.FieldHost, r.Host).
132135 Msg("panic recovered")
133136
134- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
137+ w.Header().Set("Content-Type", "application/json")
138+ w.WriteHeader(http.StatusInternalServerError)
139+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Internal Server Error"})
135140 }
136141 }()
137142
@@ -2,10 +2,13 @@
2 package middleware2 package middleware
3 3
4 import (4 import (
5+ "encoding/json"
5 "net/http"6 "net/http"
6 "time"7 "time"
7 8
8 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
10+
11+ "gordon/internal/adapters/dto"
9 )12 )
10 13
11 // ResponseWriter wraps http.ResponseWriter to capture status code and bytes written.14 // ResponseWriter wraps http.ResponseWriter to capture status code and bytes written.
@@ -131,7 +134,9 @@ func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler {
131 Str(zerowrap.FieldHost, r.Host).134 Str(zerowrap.FieldHost, r.Host).
132 Msg("panic recovered")135 Msg("panic recovered")
133 136
134- http.Error(w, "Internal Server Error", http.StatusInternalServerError)137+ w.Header().Set("Content-Type", "application/json")
138+ w.WriteHeader(http.StatusInternalServerError)
139+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Internal Server Error"})
135 }140 }
136 }()141 }()
137 142
M internal/adapters/in/http/registry/handler.go +33 -33
@@ -12,6 +12,7 @@ import (
1212
1313 "github.com/bnema/zerowrap"
1414
15+ "gordon/internal/adapters/dto"
1516 "gordon/internal/boundaries/in"
1617 "gordon/internal/domain"
1718 "gordon/pkg/manifest"
@@ -93,15 +94,15 @@ func (h *Handler) handleRegistryRoutes(w http.ResponseWriter, r *http.Request) {
9394 h.handleBase(w, r)
9495 return
9596 }
97+ h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
9698
97- http.NotFound(w, r)
9899 }
99100
100101 func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {
101102 // Parse path: /v2/{name}/manifests/{reference}
102103 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
103104 if len(parts) < 3 || parts[len(parts)-2] != "manifests" {
104- http.NotFound(w, r)
105+ h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
105106 return
106107 }
107108
@@ -127,7 +128,7 @@ func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {
127128 case "PUT":
128129 h.handlePutManifest(w, r)
129130 default:
130- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
131+ h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
131132 }
132133 }
133134
@@ -135,7 +136,7 @@ func (h *Handler) handleBlobRoutes(w http.ResponseWriter, r *http.Request) {
135136 // Parse path: /v2/{name}/blobs/{digest}
136137 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
137138 if len(parts) < 3 || parts[len(parts)-2] != "blobs" {
138- http.NotFound(w, r)
139+ h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
139140 return
140141 }
141142
@@ -159,7 +160,7 @@ func (h *Handler) handleBlobRoutes(w http.ResponseWriter, r *http.Request) {
159160 case "HEAD", "GET":
160161 h.handleGetBlob(w, r)
161162 default:
162- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
163+ h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
163164 }
164165 }
165166
@@ -168,7 +169,7 @@ func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request)
168169 path := strings.TrimPrefix(r.URL.Path, "/v2/")
169170 uploadIndex := strings.Index(path, "/blobs/uploads/")
170171 if uploadIndex == -1 {
171- http.NotFound(w, r)
172+ h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
172173 return
173174 }
174175
@@ -189,7 +190,7 @@ func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request)
189190 case "POST":
190191 h.handleStartBlobUpload(w, r)
191192 default:
192- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
193+ h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
193194 }
194195 } else {
195196 // Validate UUID to prevent path traversal
@@ -203,7 +204,7 @@ func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request)
203204 case "PATCH", "PUT":
204205 h.handleBlobUpload(w, r)
205206 default:
206- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
207+ h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
207208 }
208209 }
209210 }
@@ -212,7 +213,7 @@ func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
212213 // Parse path: /v2/{name}/tags/list
213214 path := strings.TrimPrefix(r.URL.Path, "/v2/")
214215 if !strings.HasSuffix(path, "/tags/list") {
215- http.NotFound(w, r)
216+ h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
216217 return
217218 }
218219
@@ -230,7 +231,7 @@ func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
230231 case "GET":
231232 h.handleListTags(w, r)
232233 default:
233- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
234+ h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
234235 }
235236 }
236237
@@ -244,10 +245,11 @@ func (h *Handler) sendRegistryError(w http.ResponseWriter, status int, code, mes
244245 w.Header().Set("Content-Type", "application/json")
245246 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
246247 w.WriteHeader(status)
247- _ = json.NewEncoder(w).Encode(map[string]any{
248- "errors": []map[string]string{
249- {"code": code, "message": message},
250- },
248+ _ = json.NewEncoder(w).Encode(dto.RegistryErrorResponse{
249+ Errors: []dto.RegistryErrorItem{{
250+ Code: code,
251+ Message: message,
252+ }},
251253 })
252254 }
253255
@@ -262,7 +264,7 @@ func (h *Handler) handleGetManifest(w http.ResponseWriter, r *http.Request) {
262264 manifestData, err := h.registrySvc.GetManifest(ctx, name, reference)
263265 if err != nil {
264266 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("manifest not found")
265- http.NotFound(w, r)
267+ h.sendRegistryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest not found")
266268 return
267269 }
268270
@@ -286,7 +288,7 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
286288
287289 if contentType == "" {
288290 log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing")
289- http.Error(w, "Content-Type header required", http.StatusBadRequest)
291+ h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "Content-Type header required")
290292 return
291293 }
292294
@@ -303,7 +305,7 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
303305 return
304306 }
305307 log.Error().Err(err).Msg("failed to read manifest data")
306- http.Error(w, "Bad Request", http.StatusBadRequest)
308+ h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "invalid manifest data")
307309 return
308310 }
309311
@@ -326,7 +328,7 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
326328 digest, err := h.registrySvc.PutManifest(ctx, manifestObj)
327329 if err != nil {
328330 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
329- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
331+ h.sendRegistryError(w, http.StatusInternalServerError, "MANIFEST_INVALID", "failed to store manifest")
330332 return
331333 }
332334
@@ -346,7 +348,7 @@ func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
346348 path, err := h.registrySvc.GetBlobPath(ctx, digest)
347349 if err != nil {
348350 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
349- http.NotFound(w, r)
351+ h.sendRegistryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found")
350352 return
351353 }
352354
@@ -364,7 +366,7 @@ func (h *Handler) handleStartBlobUpload(w http.ResponseWriter, r *http.Request)
364366 uuid, err := h.registrySvc.StartUpload(ctx, name)
365367 if err != nil {
366368 log.Error().Err(err).Msg("failed to start blob upload")
367- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
369+ h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to start upload")
368370 return
369371 }
370372
@@ -408,7 +410,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
408410 return
409411 }
410412 log.Error().Err(err).Msg("failed to read blob chunk")
411- http.Error(w, "Bad Request", http.StatusBadRequest)
413+ h.sendRegistryError(w, http.StatusBadRequest, "BLOB_UPLOAD_INVALID", "invalid blob chunk")
412414 return
413415 }
414416
@@ -416,7 +418,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
416418 length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, chunk)
417419 if err != nil {
418420 log.Error().Err(err).Msg("failed to append blob chunk")
419- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
421+ h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")
420422 return
421423 }
422424
@@ -425,7 +427,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
425427 if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil {
426428 log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload")
427429 _ = h.registrySvc.CancelUpload(ctx, uuid)
428- http.Error(w, "Bad Request: digest mismatch", http.StatusBadRequest)
430+ h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest mismatch")
429431 return
430432 }
431433
@@ -451,21 +453,19 @@ func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) {
451453 tags, err := h.registrySvc.ListTags(ctx, name)
452454 if err != nil {
453455 log.Warn().Err(err).Str("name", name).Msg("tags not found")
454- w.WriteHeader(http.StatusNotFound)
456+ h.sendRegistryError(w, http.StatusNotFound, "NAME_UNKNOWN", "repository not found")
455457 return
456458 }
457459
458- tagsJSON, err := json.Marshal(tags)
459- if err != nil {
460- log.Error().Err(err).Str("name", name).Msg("failed to marshal tags")
461- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
462- return
460+ response := dto.TagListResponse{
461+ Name: name,
462+ Tags: tags,
463463 }
464464
465- response := fmt.Sprintf(`{"name":"%s","tags":%s}`, name, string(tagsJSON))
466-
467465 w.Header().Set("Content-Type", "application/json")
468- w.Header().Set("Content-Length", strconv.Itoa(len(response)))
469466 w.WriteHeader(http.StatusOK)
470- w.Write([]byte(response))
467+
468+ if err := json.NewEncoder(w).Encode(response); err != nil {
469+ log.Error().Err(err).Str("name", name).Msg("failed to encode tags")
470+ }
471471 }
@@ -12,6 +12,7 @@ import (
12 12
13 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
14 14
15+ "gordon/internal/adapters/dto"
15 "gordon/internal/boundaries/in"16 "gordon/internal/boundaries/in"
16 "gordon/internal/domain"17 "gordon/internal/domain"
17 "gordon/pkg/manifest"18 "gordon/pkg/manifest"
@@ -93,15 +94,15 @@ func (h *Handler) handleRegistryRoutes(w http.ResponseWriter, r *http.Request) {
93 h.handleBase(w, r)94 h.handleBase(w, r)
94 return95 return
95 }96 }
97+ h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
96 98
97- http.NotFound(w, r)
98 }99 }
99 100
100 func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {101 func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {
101 // Parse path: /v2/{name}/manifests/{reference}102 // Parse path: /v2/{name}/manifests/{reference}
102 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")103 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
103 if len(parts) < 3 || parts[len(parts)-2] != "manifests" {104 if len(parts) < 3 || parts[len(parts)-2] != "manifests" {
104- http.NotFound(w, r)105+ h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
105 return106 return
106 }107 }
107 108
@@ -127,7 +128,7 @@ func (h *Handler) handleManifestRoutes(w http.ResponseWriter, r *http.Request) {
127 case "PUT":128 case "PUT":
128 h.handlePutManifest(w, r)129 h.handlePutManifest(w, r)
129 default:130 default:
130- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)131+ h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
131 }132 }
132 }133 }
133 134
@@ -135,7 +136,7 @@ func (h *Handler) handleBlobRoutes(w http.ResponseWriter, r *http.Request) {
135 // Parse path: /v2/{name}/blobs/{digest}136 // Parse path: /v2/{name}/blobs/{digest}
136 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")137 parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v2/"), "/")
137 if len(parts) < 3 || parts[len(parts)-2] != "blobs" {138 if len(parts) < 3 || parts[len(parts)-2] != "blobs" {
138- http.NotFound(w, r)139+ h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
139 return140 return
140 }141 }
141 142
@@ -159,7 +160,7 @@ func (h *Handler) handleBlobRoutes(w http.ResponseWriter, r *http.Request) {
159 case "HEAD", "GET":160 case "HEAD", "GET":
160 h.handleGetBlob(w, r)161 h.handleGetBlob(w, r)
161 default:162 default:
162- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)163+ h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
163 }164 }
164 }165 }
165 166
@@ -168,7 +169,7 @@ func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request)
168 path := strings.TrimPrefix(r.URL.Path, "/v2/")169 path := strings.TrimPrefix(r.URL.Path, "/v2/")
169 uploadIndex := strings.Index(path, "/blobs/uploads/")170 uploadIndex := strings.Index(path, "/blobs/uploads/")
170 if uploadIndex == -1 {171 if uploadIndex == -1 {
171- http.NotFound(w, r)172+ h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
172 return173 return
173 }174 }
174 175
@@ -189,7 +190,7 @@ func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request)
189 case "POST":190 case "POST":
190 h.handleStartBlobUpload(w, r)191 h.handleStartBlobUpload(w, r)
191 default:192 default:
192- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)193+ h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
193 }194 }
194 } else {195 } else {
195 // Validate UUID to prevent path traversal196 // Validate UUID to prevent path traversal
@@ -203,7 +204,7 @@ func (h *Handler) handleBlobUploadRoutes(w http.ResponseWriter, r *http.Request)
203 case "PATCH", "PUT":204 case "PATCH", "PUT":
204 h.handleBlobUpload(w, r)205 h.handleBlobUpload(w, r)
205 default:206 default:
206- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)207+ h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
207 }208 }
208 }209 }
209 }210 }
@@ -212,7 +213,7 @@ func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
212 // Parse path: /v2/{name}/tags/list213 // Parse path: /v2/{name}/tags/list
213 path := strings.TrimPrefix(r.URL.Path, "/v2/")214 path := strings.TrimPrefix(r.URL.Path, "/v2/")
214 if !strings.HasSuffix(path, "/tags/list") {215 if !strings.HasSuffix(path, "/tags/list") {
215- http.NotFound(w, r)216+ h.sendRegistryError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
216 return217 return
217 }218 }
218 219
@@ -230,7 +231,7 @@ func (h *Handler) handleTagListRoutes(w http.ResponseWriter, r *http.Request) {
230 case "GET":231 case "GET":
231 h.handleListTags(w, r)232 h.handleListTags(w, r)
232 default:233 default:
233- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)234+ h.sendRegistryError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
234 }235 }
235 }236 }
236 237
@@ -244,10 +245,11 @@ func (h *Handler) sendRegistryError(w http.ResponseWriter, status int, code, mes
244 w.Header().Set("Content-Type", "application/json")245 w.Header().Set("Content-Type", "application/json")
245 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")246 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
246 w.WriteHeader(status)247 w.WriteHeader(status)
247- _ = json.NewEncoder(w).Encode(map[string]any{248+ _ = json.NewEncoder(w).Encode(dto.RegistryErrorResponse{
248- "errors": []map[string]string{249+ Errors: []dto.RegistryErrorItem{{
249- {"code": code, "message": message},250+ Code: code,
250- },251+ Message: message,
252+ }},
251 })253 })
252 }254 }
253 255
@@ -262,7 +264,7 @@ func (h *Handler) handleGetManifest(w http.ResponseWriter, r *http.Request) {
262 manifestData, err := h.registrySvc.GetManifest(ctx, name, reference)264 manifestData, err := h.registrySvc.GetManifest(ctx, name, reference)
263 if err != nil {265 if err != nil {
264 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("manifest not found")266 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("manifest not found")
265- http.NotFound(w, r)267+ h.sendRegistryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest not found")
266 return268 return
267 }269 }
268 270
@@ -286,7 +288,7 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
286 288
287 if contentType == "" {289 if contentType == "" {
288 log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing")290 log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing")
289- http.Error(w, "Content-Type header required", http.StatusBadRequest)291+ h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "Content-Type header required")
290 return292 return
291 }293 }
292 294
@@ -303,7 +305,7 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
303 return305 return
304 }306 }
305 log.Error().Err(err).Msg("failed to read manifest data")307 log.Error().Err(err).Msg("failed to read manifest data")
306- http.Error(w, "Bad Request", http.StatusBadRequest)308+ h.sendRegistryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "invalid manifest data")
307 return309 return
308 }310 }
309 311
@@ -326,7 +328,7 @@ func (h *Handler) handlePutManifest(w http.ResponseWriter, r *http.Request) {
326 digest, err := h.registrySvc.PutManifest(ctx, manifestObj)328 digest, err := h.registrySvc.PutManifest(ctx, manifestObj)
327 if err != nil {329 if err != nil {
328 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")330 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("failed to store manifest")
329- http.Error(w, "Internal Server Error", http.StatusInternalServerError)331+ h.sendRegistryError(w, http.StatusInternalServerError, "MANIFEST_INVALID", "failed to store manifest")
330 return332 return
331 }333 }
332 334
@@ -346,7 +348,7 @@ func (h *Handler) handleGetBlob(w http.ResponseWriter, r *http.Request) {
346 path, err := h.registrySvc.GetBlobPath(ctx, digest)348 path, err := h.registrySvc.GetBlobPath(ctx, digest)
347 if err != nil {349 if err != nil {
348 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")350 log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("blob not found")
349- http.NotFound(w, r)351+ h.sendRegistryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob not found")
350 return352 return
351 }353 }
352 354
@@ -364,7 +366,7 @@ func (h *Handler) handleStartBlobUpload(w http.ResponseWriter, r *http.Request)
364 uuid, err := h.registrySvc.StartUpload(ctx, name)366 uuid, err := h.registrySvc.StartUpload(ctx, name)
365 if err != nil {367 if err != nil {
366 log.Error().Err(err).Msg("failed to start blob upload")368 log.Error().Err(err).Msg("failed to start blob upload")
367- http.Error(w, "Internal Server Error", http.StatusInternalServerError)369+ h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to start upload")
368 return370 return
369 }371 }
370 372
@@ -408,7 +410,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
408 return410 return
409 }411 }
410 log.Error().Err(err).Msg("failed to read blob chunk")412 log.Error().Err(err).Msg("failed to read blob chunk")
411- http.Error(w, "Bad Request", http.StatusBadRequest)413+ h.sendRegistryError(w, http.StatusBadRequest, "BLOB_UPLOAD_INVALID", "invalid blob chunk")
412 return414 return
413 }415 }
414 416
@@ -416,7 +418,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
416 length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, chunk)418 length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, chunk)
417 if err != nil {419 if err != nil {
418 log.Error().Err(err).Msg("failed to append blob chunk")420 log.Error().Err(err).Msg("failed to append blob chunk")
419- http.Error(w, "Internal Server Error", http.StatusInternalServerError)421+ h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")
420 return422 return
421 }423 }
422 424
@@ -425,7 +427,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
425 if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil {427 if err := h.registrySvc.FinishUpload(ctx, uuid, digest); err != nil {
426 log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload")428 log.Error().Err(err).Str("digest", digest).Msg("failed to finalize blob upload")
427 _ = h.registrySvc.CancelUpload(ctx, uuid)429 _ = h.registrySvc.CancelUpload(ctx, uuid)
428- http.Error(w, "Bad Request: digest mismatch", http.StatusBadRequest)430+ h.sendRegistryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest mismatch")
429 return431 return
430 }432 }
431 433
@@ -451,21 +453,19 @@ func (h *Handler) handleListTags(w http.ResponseWriter, r *http.Request) {
451 tags, err := h.registrySvc.ListTags(ctx, name)453 tags, err := h.registrySvc.ListTags(ctx, name)
452 if err != nil {454 if err != nil {
453 log.Warn().Err(err).Str("name", name).Msg("tags not found")455 log.Warn().Err(err).Str("name", name).Msg("tags not found")
454- w.WriteHeader(http.StatusNotFound)456+ h.sendRegistryError(w, http.StatusNotFound, "NAME_UNKNOWN", "repository not found")
455 return457 return
456 }458 }
457 459
458- tagsJSON, err := json.Marshal(tags)460+ response := dto.TagListResponse{
459- if err != nil {461+ Name: name,
460- log.Error().Err(err).Str("name", name).Msg("failed to marshal tags")462+ Tags: tags,
461- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
462- return
463 }463 }
464 464
465- response := fmt.Sprintf(`{"name":"%s","tags":%s}`, name, string(tagsJSON))
466-
467 w.Header().Set("Content-Type", "application/json")465 w.Header().Set("Content-Type", "application/json")
468- w.Header().Set("Content-Length", strconv.Itoa(len(response)))
469 w.WriteHeader(http.StatusOK)466 w.WriteHeader(http.StatusOK)
470- w.Write([]byte(response))467+
468+ if err := json.NewEncoder(w).Encode(response); err != nil {
469+ log.Error().Err(err).Str("name", name).Msg("failed to encode tags")
470+ }
471 }471 }
M internal/adapters/in/http/registry/handler_test.go +16 -143
@@ -2,9 +2,9 @@ package registry
22
33 import (
44 "bytes"
5+ "encoding/json"
56 "net/http"
67 "net/http/httptest"
7- "os"
88 "testing"
99
1010 "github.com/bnema/zerowrap"
@@ -33,19 +33,6 @@ func TestHandler_Base(t *testing.T) {
3333 assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
3434 }
3535
36-func TestHandler_NotFound(t *testing.T) {
37- registrySvc := inmocks.NewMockRegistryService(t)
38-
39- handler := NewHandler(registrySvc, testLogger())
40-
41- req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
42- rec := httptest.NewRecorder()
43-
44- handler.ServeHTTP(rec, req)
45-
46- assert.Equal(t, http.StatusNotFound, rec.Code)
47-}
48-
4936 func TestHandler_GetManifest_Success(t *testing.T) {
5037 registrySvc := inmocks.NewMockRegistryService(t)
5138
@@ -106,134 +93,9 @@ func TestHandler_GetManifest_NotFound(t *testing.T) {
10693 handler.ServeHTTP(rec, req)
10794
10895 assert.Equal(t, http.StatusNotFound, rec.Code)
109-}
110-
111-func TestHandler_GetManifest_NestedName(t *testing.T) {
112- registrySvc := inmocks.NewMockRegistryService(t)
113-
114- handler := NewHandler(registrySvc, testLogger())
115-
116- manifestData := []byte(`{"schemaVersion": 2}`)
117- registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
118- Name: "org/project/app",
119- Reference: "v1.0",
120- ContentType: "application/vnd.docker.distribution.manifest.v2+json",
121- Data: manifestData,
122- }, nil)
123-
124- req := httptest.NewRequest("GET", "/v2/org/project/app/manifests/v1.0", nil)
125- rec := httptest.NewRecorder()
126-
127- handler.ServeHTTP(rec, req)
128-
129- assert.Equal(t, http.StatusOK, rec.Code)
130-}
131-
132-func TestHandler_PutManifest_Success(t *testing.T) {
133- registrySvc := inmocks.NewMockRegistryService(t)
134-
135- handler := NewHandler(registrySvc, testLogger())
136-
137- manifestData := []byte(`{"schemaVersion": 2}`)
138- registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
139- return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json"
140- })).Return("sha256:abc123", nil)
141-
142- req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
143- req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
144- rec := httptest.NewRecorder()
145-
146- handler.ServeHTTP(rec, req)
147-
148- assert.Equal(t, http.StatusCreated, rec.Code)
149- assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest"))
150- assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest")
151-}
152-
153-func TestHandler_PutManifest_MissingContentType(t *testing.T) {
154- registrySvc := inmocks.NewMockRegistryService(t)
155-
156- handler := NewHandler(registrySvc, testLogger())
157-
158- manifestData := []byte(`{"schemaVersion": 2}`)
159-
160- req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
161- // No Content-Type header
162- rec := httptest.NewRecorder()
163-
164- handler.ServeHTTP(rec, req)
165-
166- assert.Equal(t, http.StatusBadRequest, rec.Code)
167-}
168-
169-func TestHandler_PutManifest_StorageError(t *testing.T) {
170- registrySvc := inmocks.NewMockRegistryService(t)
171-
172- handler := NewHandler(registrySvc, testLogger())
173-
174- manifestData := []byte(`{"schemaVersion": 2}`)
175- registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError)
176-
177- req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
178- req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
179- rec := httptest.NewRecorder()
180-
181- handler.ServeHTTP(rec, req)
182-
183- assert.Equal(t, http.StatusInternalServerError, rec.Code)
184-}
185-
186-func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
187- registrySvc := inmocks.NewMockRegistryService(t)
188-
189- handler := NewHandler(registrySvc, testLogger())
190-
191- req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
192- rec := httptest.NewRecorder()
193-
194- handler.ServeHTTP(rec, req)
195-
196- assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
197-}
198-
199-func TestHandler_GetBlob_Success(t *testing.T) {
200- registrySvc := inmocks.NewMockRegistryService(t)
201-
202- handler := NewHandler(registrySvc, testLogger())
203-
204- // Create a temp file for http.ServeFile
205- tmpFile, err := os.CreateTemp("", "blob-*")
206- assert.NoError(t, err)
207- defer tmpFile.Close()
208-
209- blobContent := []byte("blob content here")
210- _, err = tmpFile.Write(blobContent)
211- assert.NoError(t, err)
212-
213- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
214-
215- req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
216- rec := httptest.NewRecorder()
217-
218- handler.ServeHTTP(rec, req)
219-
220- assert.Equal(t, http.StatusOK, rec.Code)
221- assert.Equal(t, blobContent, rec.Body.Bytes())
222-}
223-
224-func TestHandler_GetBlob_NotFound(t *testing.T) {
225- registrySvc := inmocks.NewMockRegistryService(t)
226-
227- handler := NewHandler(registrySvc, testLogger())
228-
229- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
230-
231- req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)
232- rec := httptest.NewRecorder()
233-
234- handler.ServeHTTP(rec, req)
96+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
23597
236- assert.Equal(t, http.StatusNotFound, rec.Code)
98+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
23799 }
238100
239101 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
@@ -247,6 +109,7 @@ func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
247109 handler.ServeHTTP(rec, req)
248110
249111 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
112+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
250113 }
251114
252115 func TestHandler_StartBlobUpload_Success(t *testing.T) {
@@ -279,6 +142,7 @@ func TestHandler_StartBlobUpload_Error(t *testing.T) {
279142 handler.ServeHTTP(rec, req)
280143
281144 assert.Equal(t, http.StatusInternalServerError, rec.Code)
145+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
282146 }
283147
284148 func TestHandler_BlobUpload_PATCH(t *testing.T) {
@@ -332,6 +196,7 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
332196 handler.ServeHTTP(rec, req)
333197
334198 assert.Equal(t, http.StatusBadRequest, rec.Code)
199+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
335200 }
336201
337202 func TestHandler_ListTags_Success(t *testing.T) {
@@ -348,8 +213,14 @@ func TestHandler_ListTags_Success(t *testing.T) {
348213
349214 assert.Equal(t, http.StatusOK, rec.Code)
350215 assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
351- assert.Contains(t, rec.Body.String(), "myapp")
352- assert.Contains(t, rec.Body.String(), "latest")
216+
217+ var response struct {
218+ Name string `json:"name"`
219+ Tags []string `json:"tags"`
220+ }
221+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
222+ assert.Equal(t, "myapp", response.Name)
223+ assert.Contains(t, response.Tags, "latest")
353224 }
354225
355226 func TestHandler_ListTags_NotFound(t *testing.T) {
@@ -365,6 +236,7 @@ func TestHandler_ListTags_NotFound(t *testing.T) {
365236 handler.ServeHTTP(rec, req)
366237
367238 assert.Equal(t, http.StatusNotFound, rec.Code)
239+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
368240 }
369241
370242 func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
@@ -378,6 +250,7 @@ func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
378250 handler.ServeHTTP(rec, req)
379251
380252 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
253+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
381254 }
382255
383256 func TestHandler_RegisterRoutes(t *testing.T) {
@@ -2,9 +2,9 @@ package registry
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "encoding/json"
5 "net/http"6 "net/http"
6 "net/http/httptest"7 "net/http/httptest"
7- "os"
8 "testing"8 "testing"
9 9
10 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
@@ -33,19 +33,6 @@ func TestHandler_Base(t *testing.T) {
33 assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))33 assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
34 }34 }
35 35
36-func TestHandler_NotFound(t *testing.T) {
37- registrySvc := inmocks.NewMockRegistryService(t)
38-
39- handler := NewHandler(registrySvc, testLogger())
40-
41- req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
42- rec := httptest.NewRecorder()
43-
44- handler.ServeHTTP(rec, req)
45-
46- assert.Equal(t, http.StatusNotFound, rec.Code)
47-}
48-
49 func TestHandler_GetManifest_Success(t *testing.T) {36 func TestHandler_GetManifest_Success(t *testing.T) {
50 registrySvc := inmocks.NewMockRegistryService(t)37 registrySvc := inmocks.NewMockRegistryService(t)
51 38
@@ -106,134 +93,9 @@ func TestHandler_GetManifest_NotFound(t *testing.T) {
106 handler.ServeHTTP(rec, req)93 handler.ServeHTTP(rec, req)
107 94
108 assert.Equal(t, http.StatusNotFound, rec.Code)95 assert.Equal(t, http.StatusNotFound, rec.Code)
109-}96+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
110-
111-func TestHandler_GetManifest_NestedName(t *testing.T) {
112- registrySvc := inmocks.NewMockRegistryService(t)
113-
114- handler := NewHandler(registrySvc, testLogger())
115-
116- manifestData := []byte(`{"schemaVersion": 2}`)
117- registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
118- Name: "org/project/app",
119- Reference: "v1.0",
120- ContentType: "application/vnd.docker.distribution.manifest.v2+json",
121- Data: manifestData,
122- }, nil)
123-
124- req := httptest.NewRequest("GET", "/v2/org/project/app/manifests/v1.0", nil)
125- rec := httptest.NewRecorder()
126-
127- handler.ServeHTTP(rec, req)
128-
129- assert.Equal(t, http.StatusOK, rec.Code)
130-}
131-
132-func TestHandler_PutManifest_Success(t *testing.T) {
133- registrySvc := inmocks.NewMockRegistryService(t)
134-
135- handler := NewHandler(registrySvc, testLogger())
136-
137- manifestData := []byte(`{"schemaVersion": 2}`)
138- registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
139- return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json"
140- })).Return("sha256:abc123", nil)
141-
142- req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
143- req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
144- rec := httptest.NewRecorder()
145-
146- handler.ServeHTTP(rec, req)
147-
148- assert.Equal(t, http.StatusCreated, rec.Code)
149- assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest"))
150- assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest")
151-}
152-
153-func TestHandler_PutManifest_MissingContentType(t *testing.T) {
154- registrySvc := inmocks.NewMockRegistryService(t)
155-
156- handler := NewHandler(registrySvc, testLogger())
157-
158- manifestData := []byte(`{"schemaVersion": 2}`)
159-
160- req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
161- // No Content-Type header
162- rec := httptest.NewRecorder()
163-
164- handler.ServeHTTP(rec, req)
165-
166- assert.Equal(t, http.StatusBadRequest, rec.Code)
167-}
168-
169-func TestHandler_PutManifest_StorageError(t *testing.T) {
170- registrySvc := inmocks.NewMockRegistryService(t)
171-
172- handler := NewHandler(registrySvc, testLogger())
173-
174- manifestData := []byte(`{"schemaVersion": 2}`)
175- registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError)
176-
177- req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
178- req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
179- rec := httptest.NewRecorder()
180-
181- handler.ServeHTTP(rec, req)
182-
183- assert.Equal(t, http.StatusInternalServerError, rec.Code)
184-}
185-
186-func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
187- registrySvc := inmocks.NewMockRegistryService(t)
188-
189- handler := NewHandler(registrySvc, testLogger())
190-
191- req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
192- rec := httptest.NewRecorder()
193-
194- handler.ServeHTTP(rec, req)
195-
196- assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
197-}
198-
199-func TestHandler_GetBlob_Success(t *testing.T) {
200- registrySvc := inmocks.NewMockRegistryService(t)
201-
202- handler := NewHandler(registrySvc, testLogger())
203-
204- // Create a temp file for http.ServeFile
205- tmpFile, err := os.CreateTemp("", "blob-*")
206- assert.NoError(t, err)
207- defer tmpFile.Close()
208-
209- blobContent := []byte("blob content here")
210- _, err = tmpFile.Write(blobContent)
211- assert.NoError(t, err)
212-
213- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
214-
215- req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
216- rec := httptest.NewRecorder()
217-
218- handler.ServeHTTP(rec, req)
219-
220- assert.Equal(t, http.StatusOK, rec.Code)
221- assert.Equal(t, blobContent, rec.Body.Bytes())
222-}
223-
224-func TestHandler_GetBlob_NotFound(t *testing.T) {
225- registrySvc := inmocks.NewMockRegistryService(t)
226-
227- handler := NewHandler(registrySvc, testLogger())
228-
229- registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
230-
231- req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)
232- rec := httptest.NewRecorder()
233-
234- handler.ServeHTTP(rec, req)
235 97
236- assert.Equal(t, http.StatusNotFound, rec.Code)98+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
237 }99 }
238 100
239 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {101 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
@@ -247,6 +109,7 @@ func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
247 handler.ServeHTTP(rec, req)109 handler.ServeHTTP(rec, req)
248 110
249 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)111 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
112+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
250 }113 }
251 114
252 func TestHandler_StartBlobUpload_Success(t *testing.T) {115 func TestHandler_StartBlobUpload_Success(t *testing.T) {
@@ -279,6 +142,7 @@ func TestHandler_StartBlobUpload_Error(t *testing.T) {
279 handler.ServeHTTP(rec, req)142 handler.ServeHTTP(rec, req)
280 143
281 assert.Equal(t, http.StatusInternalServerError, rec.Code)144 assert.Equal(t, http.StatusInternalServerError, rec.Code)
145+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
282 }146 }
283 147
284 func TestHandler_BlobUpload_PATCH(t *testing.T) {148 func TestHandler_BlobUpload_PATCH(t *testing.T) {
@@ -332,6 +196,7 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
332 handler.ServeHTTP(rec, req)196 handler.ServeHTTP(rec, req)
333 197
334 assert.Equal(t, http.StatusBadRequest, rec.Code)198 assert.Equal(t, http.StatusBadRequest, rec.Code)
199+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
335 }200 }
336 201
337 func TestHandler_ListTags_Success(t *testing.T) {202 func TestHandler_ListTags_Success(t *testing.T) {
@@ -348,8 +213,14 @@ func TestHandler_ListTags_Success(t *testing.T) {
348 213
349 assert.Equal(t, http.StatusOK, rec.Code)214 assert.Equal(t, http.StatusOK, rec.Code)
350 assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))215 assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
351- assert.Contains(t, rec.Body.String(), "myapp")216+
352- assert.Contains(t, rec.Body.String(), "latest")217+ var response struct {
218+ Name string `json:"name"`
219+ Tags []string `json:"tags"`
220+ }
221+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
222+ assert.Equal(t, "myapp", response.Name)
223+ assert.Contains(t, response.Tags, "latest")
353 }224 }
354 225
355 func TestHandler_ListTags_NotFound(t *testing.T) {226 func TestHandler_ListTags_NotFound(t *testing.T) {
@@ -365,6 +236,7 @@ func TestHandler_ListTags_NotFound(t *testing.T) {
365 handler.ServeHTTP(rec, req)236 handler.ServeHTTP(rec, req)
366 237
367 assert.Equal(t, http.StatusNotFound, rec.Code)238 assert.Equal(t, http.StatusNotFound, rec.Code)
239+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
368 }240 }
369 241
370 func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {242 func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
@@ -378,6 +250,7 @@ func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
378 handler.ServeHTTP(rec, req)250 handler.ServeHTTP(rec, req)
379 251
380 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)252 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
253+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
381 }254 }
382 255
383 func TestHandler_RegisterRoutes(t *testing.T) {256 func TestHandler_RegisterRoutes(t *testing.T) {
M internal/adapters/in/http/registry/token.go +12 -13
@@ -8,18 +8,11 @@ import (
88
99 "github.com/bnema/zerowrap"
1010
11+ "gordon/internal/adapters/dto"
1112 "gordon/internal/boundaries/in"
1213 "gordon/internal/domain"
1314 )
1415
15-// TokenResponse represents the response from the token server.
16-type TokenResponse struct {
17- Token string `json:"token"`
18- AccessToken string `json:"access_token,omitempty"`
19- ExpiresIn int `json:"expires_in,omitempty"`
20- IssuedAt string `json:"issued_at,omitempty"`
21-}
22-
2316 // InternalAuth holds credentials for internal loopback registry access.
2417 type InternalAuth struct {
2518 Username string
@@ -60,7 +53,9 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
6053 log := zerowrap.FromCtx(ctx)
6154
6255 if r.Method != http.MethodGet {
63- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
56+ w.Header().Set("Content-Type", "application/json")
57+ w.WriteHeader(http.StatusMethodNotAllowed)
58+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Method not allowed"})
6459 return
6560 }
6661
@@ -103,7 +98,9 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
10398 Str("username", username).
10499 Msg("token request authentication failed")
105100 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
106- http.Error(w, "Unauthorized", http.StatusUnauthorized)
101+ w.Header().Set("Content-Type", "application/json")
102+ w.WriteHeader(http.StatusUnauthorized)
103+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
107104 return
108105 }
109106
@@ -115,11 +112,13 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
115112 accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
116113 if err != nil {
117114 log.Error().Err(err).Msg("failed to generate access token")
118- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
115+ w.Header().Set("Content-Type", "application/json")
116+ w.WriteHeader(http.StatusInternalServerError)
117+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Internal Server Error"})
119118 return
120119 }
121120
122- response := TokenResponse{
121+ response := dto.TokenResponse{
123122 Token: accessToken,
124123 ExpiresIn: 300, // 5 minutes in seconds
125124 IssuedAt: time.Now().Format(time.RFC3339),
@@ -141,7 +140,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
141140 // sendAnonymousToken sends a token for anonymous/unauthenticated access.
142141 func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
143142 // For anonymous access, we issue a very short-lived token with limited scope
144- response := TokenResponse{
143+ response := dto.TokenResponse{
145144 Token: "", // Empty token indicates limited access
146145 ExpiresIn: 60, // 1 minute
147146 IssuedAt: time.Now().Format(time.RFC3339),
@@ -8,18 +8,11 @@ import (
8 8
9 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
10 10
11+ "gordon/internal/adapters/dto"
11 "gordon/internal/boundaries/in"12 "gordon/internal/boundaries/in"
12 "gordon/internal/domain"13 "gordon/internal/domain"
13 )14 )
14 15
15-// TokenResponse represents the response from the token server.
16-type TokenResponse struct {
17- Token string `json:"token"`
18- AccessToken string `json:"access_token,omitempty"`
19- ExpiresIn int `json:"expires_in,omitempty"`
20- IssuedAt string `json:"issued_at,omitempty"`
21-}
22-
23 // InternalAuth holds credentials for internal loopback registry access.16 // InternalAuth holds credentials for internal loopback registry access.
24 type InternalAuth struct {17 type InternalAuth struct {
25 Username string18 Username string
@@ -60,7 +53,9 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
60 log := zerowrap.FromCtx(ctx)53 log := zerowrap.FromCtx(ctx)
61 54
62 if r.Method != http.MethodGet {55 if r.Method != http.MethodGet {
63- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)56+ w.Header().Set("Content-Type", "application/json")
57+ w.WriteHeader(http.StatusMethodNotAllowed)
58+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Method not allowed"})
64 return59 return
65 }60 }
66 61
@@ -103,7 +98,9 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
103 Str("username", username).98 Str("username", username).
104 Msg("token request authentication failed")99 Msg("token request authentication failed")
105 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)100 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
106- http.Error(w, "Unauthorized", http.StatusUnauthorized)101+ w.Header().Set("Content-Type", "application/json")
102+ w.WriteHeader(http.StatusUnauthorized)
103+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
107 return104 return
108 }105 }
109 106
@@ -115,11 +112,13 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
115 accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)112 accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
116 if err != nil {113 if err != nil {
117 log.Error().Err(err).Msg("failed to generate access token")114 log.Error().Err(err).Msg("failed to generate access token")
118- http.Error(w, "Internal Server Error", http.StatusInternalServerError)115+ w.Header().Set("Content-Type", "application/json")
116+ w.WriteHeader(http.StatusInternalServerError)
117+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Internal Server Error"})
119 return118 return
120 }119 }
121 120
122- response := TokenResponse{121+ response := dto.TokenResponse{
123 Token: accessToken,122 Token: accessToken,
124 ExpiresIn: 300, // 5 minutes in seconds123 ExpiresIn: 300, // 5 minutes in seconds
125 IssuedAt: time.Now().Format(time.RFC3339),124 IssuedAt: time.Now().Format(time.RFC3339),
@@ -141,7 +140,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
141 // sendAnonymousToken sends a token for anonymous/unauthenticated access.140 // sendAnonymousToken sends a token for anonymous/unauthenticated access.
142 func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {141 func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
143 // For anonymous access, we issue a very short-lived token with limited scope142 // For anonymous access, we issue a very short-lived token with limited scope
144- response := TokenResponse{143+ response := dto.TokenResponse{
145 Token: "", // Empty token indicates limited access144 Token: "", // Empty token indicates limited access
146 ExpiresIn: 60, // 1 minute145 ExpiresIn: 60, // 1 minute
147 IssuedAt: time.Now().Format(time.RFC3339),146 IssuedAt: time.Now().Format(time.RFC3339),

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close